Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccdb354f8 | ||
|
|
4321eae647 | ||
|
|
03e71bfdf2 | ||
|
|
23323c91e0 | ||
|
|
8dbc4b7e62 | ||
|
|
e4a5d42b85 | ||
|
|
470eaf5d80 | ||
|
|
a41626955e | ||
|
|
be889681a9 | ||
|
|
f93e1c5898 | ||
|
|
2d67e3d57f | ||
|
|
1a32e4a228 | ||
|
|
b318aa66cc | ||
|
|
6cbe29fef1 | ||
|
|
96b5f2d91f | ||
|
|
ee004c1c62 | ||
|
|
b00552f617 | ||
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b | ||
|
|
ae8e5b1bc6 | ||
|
|
ad69371f6a | ||
|
|
3f97084246 | ||
|
|
74dfc3a725 | ||
|
|
6442325926 | ||
|
|
4c4b3b6c2d | ||
|
|
4d8cb58550 | ||
|
|
a6172c4323 | ||
|
|
b70a679c64 | ||
|
|
f39bda110a | ||
|
|
ab8ecd65fe | ||
|
|
de0771d797 | ||
|
|
22e4266d38 | ||
|
|
e3332d1e27 | ||
|
|
5cebf163c5 | ||
|
|
cfd85ff9c3 | ||
|
|
c6f479750f | ||
|
|
386a8ad531 | ||
|
|
bcd7e409ba | ||
|
|
88f35e2c20 | ||
|
|
d2194da28e | ||
|
|
2f1d592497 | ||
|
|
2b6f1ba9d7 | ||
|
|
4a017f6290 | ||
|
|
7bab30aa71 | ||
|
|
77b95289e7 | ||
|
|
5b894b9dbc | ||
|
|
187ac9aa84 | ||
|
|
629bd8d84f | ||
|
|
7152d11007 | ||
|
|
a03d907128 | ||
|
|
91569e12f4 | ||
|
|
68f0d68980 | ||
|
|
8790b98766 | ||
|
|
8846cba40c | ||
|
|
0cc6ad686a | ||
|
|
612cb67438 | ||
|
|
cd5d8b503b | ||
|
|
aa59431403 | ||
|
|
269701410f | ||
|
|
3cb8096141 | ||
|
|
1f667e4a4b | ||
|
|
d5f7e290ab | ||
|
|
f2ba798764 | ||
|
|
d7ee87f22d | ||
|
|
2587e6bcfe | ||
|
|
755392e0fa | ||
|
|
a9fb428a0a | ||
|
|
bbb485b9cf | ||
|
|
3a94a69be3 | ||
|
|
e4028f4bcd | ||
|
|
7c10517bf5 | ||
|
|
cd4e5b1117 | ||
|
|
a13ac7d917 | ||
|
|
4e28098a4e | ||
|
|
52e98a71f4 | ||
|
|
81ff6a9b0e | ||
|
|
be52a23868 | ||
|
|
fc1561804c | ||
|
|
1288b3f998 | ||
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e | ||
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 |
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── DXpeditions (ADXO announcements + DX-World news) ────────────────────
|
||||||
|
//
|
||||||
|
// What a logger can say that a news reader cannot: whether the announced
|
||||||
|
// operation is worth chasing. Every activation is judged against THIS log —
|
||||||
|
// the same verdict the cluster paints on a spot — so the list reads as "what I
|
||||||
|
// still need", not "what is on the air".
|
||||||
|
|
||||||
|
// DXpedition is one announced operation plus what it is worth here.
|
||||||
|
type DXpedition struct {
|
||||||
|
dxped.Activation
|
||||||
|
// Status is the strongest verdict across the announced callsigns, bands and
|
||||||
|
// modes: "new" (entity never worked) beats "new-band-mode", which beats
|
||||||
|
// "new-band", "new-mode", "new-slot", and finally "worked". Empty when the
|
||||||
|
// callsign resolves to no entity — a prefix ADXO knows and cty.dat does not.
|
||||||
|
Status string `json:"status_chase"`
|
||||||
|
// Unconfirmed marks a need that is only a missing QSL, so the badge can be
|
||||||
|
// drawn dimmed exactly as it is in the cluster and the decode list.
|
||||||
|
Unconfirmed bool `json:"unconfirmed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseRank orders the verdicts from "most worth chasing" down. The DXpedition
|
||||||
|
// list shows ONE badge, so the ranking is the whole decision.
|
||||||
|
var chaseRank = map[string]int{
|
||||||
|
"new": 6,
|
||||||
|
"new-band-mode": 5,
|
||||||
|
"new-band": 4,
|
||||||
|
"new-mode": 3,
|
||||||
|
"new-slot": 2,
|
||||||
|
"worked": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXpeditions returns the announced operations, freshest feed permitting,
|
||||||
|
// each carrying its chase verdict.
|
||||||
|
func (a *App) GetDXpeditions() ([]DXpedition, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
acts, err := a.dxped.Activations(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: adxo fetch: %v", err)
|
||||||
|
if len(acts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Stale data with a logged error beats an empty tab.
|
||||||
|
}
|
||||||
|
out := make([]DXpedition, 0, len(acts))
|
||||||
|
for _, act := range acts {
|
||||||
|
out = append(out, DXpedition{Activation: act, Status: "", Unconfirmed: false})
|
||||||
|
}
|
||||||
|
a.judgeDXpeditions(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeDXpeditions fills in the chase verdict, in place.
|
||||||
|
//
|
||||||
|
// One ClusterSpotStatuses call for the whole list rather than one per row: the
|
||||||
|
// worked-index it builds is the expensive part (a full pass over the log), and
|
||||||
|
// asking it forty times to answer forty rows was the difference between a tab
|
||||||
|
// that opens and a tab that stalls a remote MySQL for a second.
|
||||||
|
func (a *App) judgeDXpeditions(list []DXpedition) {
|
||||||
|
if a.qso == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type slot struct{ row int }
|
||||||
|
var queries []SpotQuery
|
||||||
|
var owners []slot
|
||||||
|
for i, d := range list {
|
||||||
|
calls := d.Calls
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(d.Callsign)); c != "" {
|
||||||
|
calls = []string{c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, call := range calls {
|
||||||
|
// No announced band/mode: ask the entity-level question alone.
|
||||||
|
if len(d.Bands) == 0 && len(d.Modes) == 0 {
|
||||||
|
queries = append(queries, SpotQuery{Call: call})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bands := d.Bands
|
||||||
|
if len(bands) == 0 {
|
||||||
|
bands = []string{""}
|
||||||
|
}
|
||||||
|
modes := d.Modes
|
||||||
|
if len(modes) == 0 {
|
||||||
|
modes = []string{""}
|
||||||
|
}
|
||||||
|
for _, b := range bands {
|
||||||
|
for _, m := range modes {
|
||||||
|
queries = append(queries, SpotQuery{Call: call, Band: b, Mode: m})
|
||||||
|
owners = append(owners, slot{i})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := a.ClusterSpotStatuses(queries)
|
||||||
|
for i, r := range res {
|
||||||
|
if i >= len(owners) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
row := owners[i].row
|
||||||
|
if chaseRank[r.Status] > chaseRank[list[row].Status] {
|
||||||
|
list[row].Status = r.Status
|
||||||
|
list[row].Unconfirmed = r.UnconfStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
|
||||||
|
// of each one so the reader can watch them.
|
||||||
|
//
|
||||||
|
// Deliberately NOT judged against the log. A headline names no band, so the
|
||||||
|
// only verdict available is entity-level, and a pane of "NEW DXCC" and
|
||||||
|
// "worked" badges turned out to say nothing an operator could act on — the
|
||||||
|
// same two words on every row is noise wearing the clothes of information.
|
||||||
|
// The announcements pane, which knows the bands and modes, is where a chase
|
||||||
|
// verdict is worth drawing.
|
||||||
|
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
news, err := a.dxped.News(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dxped: dx-world fetch: %v", err)
|
||||||
|
if len(news) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshDXpeditions drops both caches so the next read goes to the network.
|
||||||
|
// Wired to the tab's refresh button: an operator who has just read of a landing
|
||||||
|
// on a cluster should not wait out the cache to see it here.
|
||||||
|
func (a *App) RefreshDXpeditions() {
|
||||||
|
if a.dxped == nil {
|
||||||
|
a.dxped = dxped.New()
|
||||||
|
}
|
||||||
|
a.dxped.Invalidate()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtHamqthPassword: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
keyExtCloudlogAPIKey: true,
|
keyExtCloudlogAPIKey: true,
|
||||||
// The web-publish config is one JSON blob and the FTP password lives inside
|
// The web-publish config is one JSON blob and the FTP password lives inside
|
||||||
|
|||||||
+95
-7
@@ -42,6 +42,62 @@ func (a *App) SetWatchlistContestPattern(p string) {
|
|||||||
a.setSettingGlobal(keyWatchlistContestPattern, p)
|
a.setSettingGlobal(keyWatchlistContestPattern, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The named contest callsigns: the other half of the auto-add, and the half the
|
||||||
|
// pattern cannot express.
|
||||||
|
//
|
||||||
|
// A special-event fleet is usually a common string in the callsign — WWA in
|
||||||
|
// TM29WWA, HB9WWA, F4WWA/P — and the pattern collects those on sight. But an
|
||||||
|
// entry list is not a naming convention: R7W can be part of the same event and
|
||||||
|
// share nothing with it, and no pattern will ever catch that station without
|
||||||
|
// catching half the band with it. So the two work together: the pattern for the
|
||||||
|
// family, this list for everybody else.
|
||||||
|
//
|
||||||
|
// Stored GLOBALLY, like the pattern and the list itself — an event is worth
|
||||||
|
// hunting whichever station profile is on.
|
||||||
|
func (a *App) GetWatchlistContestCalls() string {
|
||||||
|
return a.settingOr(keyWatchlistContestCalls, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWatchlistContestCalls stores the list as typed and caches the parsed set.
|
||||||
|
//
|
||||||
|
// The RAW text is what is stored: the operator's line breaks and their order
|
||||||
|
// are how the list is read back a week later, and rewriting it into a
|
||||||
|
// normalised single line loses the only structure it has.
|
||||||
|
func (a *App) SetWatchlistContestCalls(list string) {
|
||||||
|
a.setSettingGlobal(keyWatchlistContestCalls, list)
|
||||||
|
a.watchCalls.Store(parseContestCalls(list))
|
||||||
|
applog.Printf("watchlist: %d named contest callsigns", len(parseContestCalls(list)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContestCalls splits the box into a set. One per line is what is asked
|
||||||
|
// for, and commas, semicolons and spaces are accepted too: a list pasted from
|
||||||
|
// an announcement arrives in whatever shape the announcement used.
|
||||||
|
func parseContestCalls(list string) map[string]struct{} {
|
||||||
|
out := map[string]struct{}{}
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToUpper(list), func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
}) {
|
||||||
|
if tok = strings.TrimSpace(tok); tok != "" {
|
||||||
|
out[tok] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNamedContestCall answers the hot path from the cached set.
|
||||||
|
func (a *App) isNamedContestCall(call string) bool {
|
||||||
|
v := a.watchCalls.Load()
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set, ok := v.(map[string]struct{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, found := set[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
// WatchlistEntries returns the list for the tab.
|
// WatchlistEntries returns the list for the tab.
|
||||||
func (a *App) WatchlistEntries() []watchlist.Entry {
|
func (a *App) WatchlistEntries() []watchlist.Entry {
|
||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
@@ -55,7 +111,11 @@ func (a *App) WatchlistAdd(callsign string, contest bool) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Add(callsign, contest)
|
if err := a.watchlist.Add(callsign, contest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, true)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistRemove deletes an entry.
|
// WatchlistRemove deletes an entry.
|
||||||
@@ -63,7 +123,27 @@ func (a *App) WatchlistRemove(callsign string) error {
|
|||||||
if a.watchlist == nil {
|
if a.watchlist == nil {
|
||||||
return fmt.Errorf("watchlist not initialized")
|
return fmt.Errorf("watchlist not initialized")
|
||||||
}
|
}
|
||||||
return a.watchlist.Remove(callsign)
|
if err := a.watchlist.Remove(callsign); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.notifyWatchlist(callsign, false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWatchlist announces a membership change to the UI.
|
||||||
|
//
|
||||||
|
// Emitted from the BINDINGS rather than from the watchlist panel, because a
|
||||||
|
// call can now be added from three places (the panel, the cluster's menu, the
|
||||||
|
// DXpeditions tab) and an operator who added one from the cluster saw nothing
|
||||||
|
// at all — the confirmation lived inside a panel they were not looking at.
|
||||||
|
func (a *App) notifyWatchlist(callsign string, added bool) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "watchlist:changed", map[string]any{
|
||||||
|
"call": strings.ToUpper(strings.TrimSpace(callsign)),
|
||||||
|
"added": added,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
// WatchlistSetNotify arms the existing alert path (sound + toast) for an entry.
|
||||||
@@ -186,12 +266,20 @@ func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int6
|
|||||||
}
|
}
|
||||||
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
entry, notify, ok := a.watchlist.MarkSeen(dxCall)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not watched yet — the auto-contest pattern may claim it. Contains, not
|
// Not watched yet — the contest pattern or the named list may claim it.
|
||||||
// prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P).
|
// Contains, not prefix, for the pattern: the event string sits anywhere
|
||||||
if p := a.GetWatchlistContestPattern(); p != "" &&
|
// in those calls (HB9WWA, F4WWA/P). The named list is exact, and is what
|
||||||
strings.Contains(strings.ToUpper(dxCall), p) {
|
// catches the entries whose callsign says nothing about the event.
|
||||||
|
p := a.GetWatchlistContestPattern()
|
||||||
|
byPattern := p != "" && strings.Contains(strings.ToUpper(dxCall), p)
|
||||||
|
byName := a.isNamedContestCall(dxCall)
|
||||||
|
if byPattern || byName {
|
||||||
if err := a.watchlist.Add(dxCall, true); err == nil {
|
if err := a.watchlist.Add(dxCall, true); err == nil {
|
||||||
applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p)
|
why := fmt.Sprintf("contest pattern %q", p)
|
||||||
|
if byName {
|
||||||
|
why = "named in the contest list"
|
||||||
|
}
|
||||||
|
applog.Printf("watchlist: auto-added %s (%s)", dxCall, why)
|
||||||
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
|
entry, notify, ok = a.watchlist.MarkSeen(dxCall)
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
wruntime.EventsEmit(a.ctx, "watchlist:changed")
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13),
|
||||||
|
// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a
|
||||||
|
// new band for its entity is highlighted where the operator is actually
|
||||||
|
// looking. The verdicts come from the same cluster status cache that colours
|
||||||
|
// the spot grid, so the two windows can never disagree.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
udp "hamlog/internal/integrations/udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyWsjtHighlight = "udp.wsjt.highlight"
|
||||||
|
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
||||||
|
keyWsjtHLWorked = "udp.wsjt.highlight_worked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
||||||
|
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
|
||||||
|
// business and is not sent.
|
||||||
|
var wsjtModes = map[string]bool{
|
||||||
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
|
||||||
|
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
|
||||||
|
func (a *App) GetWsjtFollowMode() bool {
|
||||||
|
return a.settingOr(keyWsjtFollowMode, "1") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtFollowMode flips it.
|
||||||
|
func (a *App) SetWsjtFollowMode(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtFollowMode, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
|
||||||
|
// the frontend after a spot click has tuned the radio. A no-op for modes the
|
||||||
|
// decoder does not speak, and when the option is off or nothing is connected.
|
||||||
|
func (a *App) ConfigureDecoderMode(mode string) {
|
||||||
|
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.udp.SendConfigureMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The palette. Fixed colours, not theme tokens — they are painted into another
|
||||||
|
// application's window, which has no idea what theme OpsLog wears.
|
||||||
|
var (
|
||||||
|
hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink
|
||||||
|
hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green
|
||||||
|
hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange
|
||||||
|
hlWhite = udp.RGB{R: 255, G: 255, B: 255}
|
||||||
|
hlBlack = udp.RGB{R: 20, G: 20, B: 20}
|
||||||
|
// Worked already, on this band and in this mode. Grey on purpose, and the
|
||||||
|
// only DIM colour of the four: the others say "look at this", and this one
|
||||||
|
// says the opposite — it has to recede, not compete with them.
|
||||||
|
hlWorked = udp.RGB{R: 75, G: 85, B: 99}
|
||||||
|
hlWorkedFg = udp.RGB{R: 203, G: 213, B: 225}
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetWsjtHighlightWorked reports whether stations already worked on this band
|
||||||
|
// and mode are greyed out as well.
|
||||||
|
//
|
||||||
|
// Its own switch, and off by default. The other three verdicts pick out a
|
||||||
|
// handful of decodes in a period; this one can match most of them on a
|
||||||
|
// well-filled log, and a screen where nearly every line is coloured has stopped
|
||||||
|
// saying anything. It is worth having only for an operator who wants the dupes
|
||||||
|
// struck out rather than the catches picked out.
|
||||||
|
func (a *App) GetWsjtHighlightWorked() bool {
|
||||||
|
return a.settingOr(keyWsjtHLWorked, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlightWorked flips it, and repaints.
|
||||||
|
//
|
||||||
|
// Turning it OFF has to clear what it painted: those callsigns keep their grey
|
||||||
|
// in the decoder's window otherwise, and nothing would ever say another word
|
||||||
|
// about them — the de-duplication remembers that they were already told.
|
||||||
|
func (a *App) SetWsjtHighlightWorked(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtHLWorked, v)
|
||||||
|
a.wsjtHLWorkedOn.Store(on)
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: worked stations %v", on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearWsjtHighlights wipes every instruction OpsLog installed, in every
|
||||||
|
// running decoder, and forgets what it had said.
|
||||||
|
func (a *App) clearWsjtHighlights() {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, inst := range a.udp.Instances() {
|
||||||
|
_ = a.udp.SendClearHighlights(inst)
|
||||||
|
}
|
||||||
|
a.wsjtHLMu.Lock()
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWsjtHighlight reports whether decode highlighting is on.
|
||||||
|
func (a *App) GetWsjtHighlight() bool {
|
||||||
|
return a.settingOr(keyWsjtHighlight, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also
|
||||||
|
// clears every instruction OpsLog installed in the running applications — a
|
||||||
|
// disabled option that leaves stale colours behind looks broken, not disabled.
|
||||||
|
func (a *App) SetWsjtHighlight(on bool) {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
a.setSetting(keyWsjtHighlight, v)
|
||||||
|
a.wsjtHighlightOn.Store(on)
|
||||||
|
if !on {
|
||||||
|
a.clearWsjtHighlights()
|
||||||
|
applog.Printf("wsjt highlight: off — cleared in every instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeHighlightDecode paints one decoded callsign in the instance that heard
|
||||||
|
// it, when the option is on and the verdict is worth a colour. De-duplicated
|
||||||
|
// per instance+call+verdict: a station CQing all evening is decoded four times
|
||||||
|
// a minute, and the instruction only needs to be said once.
|
||||||
|
func (a *App) maybeHighlightDecode(instance, call, band, mode string) {
|
||||||
|
if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bg, fg, verdict := a.decodeHighlightVerdict(call, band, mode)
|
||||||
|
// The MODE is part of the key: one receiver can be handed FT8 and FT4 in
|
||||||
|
// the same session, and the verdict on a callsign is not the same in both.
|
||||||
|
key := instance + "|" + strings.ToUpper(call) + "|" + band + "|" + strings.ToUpper(mode)
|
||||||
|
a.wsjtHLMu.Lock()
|
||||||
|
if a.wsjtHLSent == nil {
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
}
|
||||||
|
if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few
|
||||||
|
a.wsjtHLSent = map[string]string{}
|
||||||
|
}
|
||||||
|
prev, had := a.wsjtHLSent[key]
|
||||||
|
if had && prev == verdict {
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.wsjtHLSent[key] = verdict
|
||||||
|
a.wsjtHLMu.Unlock()
|
||||||
|
if verdict == "" {
|
||||||
|
// Was highlighted under an earlier verdict and no longer deserves it
|
||||||
|
// (the operator just worked them): clear that one callsign.
|
||||||
|
if had && prev != "" {
|
||||||
|
_ = a.udp.SendHighlight(instance, call, nil, nil, false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.udp.SendHighlight(instance, call, bg, fg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats
|
||||||
|
// new-band, and "already worked here" comes last of all — it is the only
|
||||||
|
// verdict that says do NOT call, so anything worth calling for outranks it.
|
||||||
|
// Anything else is "no colour", and the empty verdict doubles as the clear
|
||||||
|
// signal in maybeHighlightDecode.
|
||||||
|
func (a *App) decodeHighlightVerdict(call, band, mode string) (bg, fg *udp.RGB, verdict string) {
|
||||||
|
if a.watchlist != nil {
|
||||||
|
if _, ok := a.watchlist.Match(call); ok {
|
||||||
|
c := hlWatchlist
|
||||||
|
f := hlBlack
|
||||||
|
return &c, &f, "watchlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c := a.clusterStatusMaps()
|
||||||
|
if a.dxcc != nil {
|
||||||
|
if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
|
||||||
|
num := dxcc.EntityDXCC(m.Entity.Name)
|
||||||
|
ent := c.entities[num]
|
||||||
|
if ent == nil {
|
||||||
|
bgc, fgc := hlNewDXCC, hlWhite
|
||||||
|
return &bgc, &fgc, "new-dxcc"
|
||||||
|
}
|
||||||
|
if band != "" {
|
||||||
|
if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand {
|
||||||
|
bgc, fgc := hlNewBand, hlBlack
|
||||||
|
return &bgc, &fgc, "new-band"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Worked already, this exact callsign on this band in this mode — a dupe,
|
||||||
|
// judged by the same ledger and the same digital-mode grouping the cluster
|
||||||
|
// uses, so the two windows cannot disagree about what "worked" means.
|
||||||
|
if a.wsjtHLWorkedOn.Load() && band != "" && mode != "" {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if c.normMode != nil {
|
||||||
|
m = c.normMode(m)
|
||||||
|
}
|
||||||
|
if _, ok := c.workedCallSlots[strings.ToUpper(call)+"|"+strings.ToLower(band)+"|"+m]; ok {
|
||||||
|
bgc, fgc := hlWorked, hlWorkedFg
|
||||||
|
return &bgc, &fgc, "worked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, ""
|
||||||
|
}
|
||||||
+688
@@ -0,0 +1,688 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Auto-call — the wiring around internal/autocall.
|
||||||
|
//
|
||||||
|
// The DECISION is in that package, alone and tested. This file does the three
|
||||||
|
// things it cannot do for itself: cut the decode stream into periods, tell it
|
||||||
|
// what the log still needs from each station, and carry out what it decides.
|
||||||
|
//
|
||||||
|
// It lives in the backend rather than in the panel because it keys a
|
||||||
|
// transmitter: it must behave identically whether the FT decodes tab is open,
|
||||||
|
// behind another tab, or the window is minimised — and because every rule it
|
||||||
|
// applies is then a Go test rather than something only the air can check.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyAutoCallOn = "autocall.enabled"
|
||||||
|
keyAutoCallOnly = "autocall.only"
|
||||||
|
keyAutoCallAttempts = "autocall.attempts"
|
||||||
|
keyAutoCallWatched = "autocall.watched_attempts"
|
||||||
|
keyAutoCallMisses = "autocall.misses"
|
||||||
|
keyAutoCallRounds = "autocall.max_rounds"
|
||||||
|
keyAutoCallRestMin = "autocall.rest_min"
|
||||||
|
keyAutoCallOnScreen = "autocall.on_screen_only"
|
||||||
|
keyAutoCallTrace = "autocall.trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
||||||
|
// is what the operator is asked for.
|
||||||
|
type AutoCallSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Only string `json:"only"`
|
||||||
|
// Attempts / WatchedAttempts: how many calls one station gets before it is
|
||||||
|
// released. The larger allowance is for a callsign on the watch list.
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
WatchedAttempts int `json:"watched_attempts"`
|
||||||
|
// Misses: periods in which the station itself transmits, with no decode of
|
||||||
|
// it, before it is given up on.
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
// MaxRounds: how many series of calls one station gets in a session, and
|
||||||
|
// RestMin the pause between two of them.
|
||||||
|
MaxRounds int `json:"max_rounds"`
|
||||||
|
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 {
|
||||||
|
d := autocall.Defaults()
|
||||||
|
num := func(key string, def int) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(a.settingOr(key, "")))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return AutoCallSettings{
|
||||||
|
// Never on from a stored value alone — see startAutoCall.
|
||||||
|
Enabled: a.settingOr(keyAutoCallOn, "0") == "1",
|
||||||
|
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
||||||
|
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
||||||
|
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
||||||
|
// On by default: the filters are in front of the operator, and a station
|
||||||
|
// they have hidden is one they have said they do not want.
|
||||||
|
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 {
|
||||||
|
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
|
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{
|
||||||
|
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||||
|
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||||
|
keyAutoCallRestMin: s.RestMin,
|
||||||
|
} {
|
||||||
|
if v > 0 {
|
||||||
|
a.setSetting(key, strconv.Itoa(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.applyAutoCall()
|
||||||
|
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
|
||||||
|
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCallOnly is the chase-list field in the decodes toolbar.
|
||||||
|
//
|
||||||
|
// Its own binding rather than a settings round-trip: the toolbar knows one
|
||||||
|
// field, and handing back a whole struct it never read is how a Preferences
|
||||||
|
// window left open somewhere quietly reverts a limit that was just changed.
|
||||||
|
func (a *App) SetAutoCallOnly(list string) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Only = list
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoCall is the toolbar switch above the decodes.
|
||||||
|
func (a *App) SetAutoCall(on bool) error {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
s.Enabled = on
|
||||||
|
return a.SaveAutoCallSettings(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallEngine returns the engine, built on first use.
|
||||||
|
func (a *App) autoCallEngine() *autocall.Engine {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if a.ac == nil {
|
||||||
|
a.ac = autocall.New(a.autoCallSettings())
|
||||||
|
}
|
||||||
|
return a.ac
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSettings() autocall.Settings {
|
||||||
|
s := a.GetAutoCallSettings()
|
||||||
|
return autocall.Settings{
|
||||||
|
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
|
||||||
|
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
||||||
|
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
||||||
|
Rest: time.Duration(s.RestMin) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAutoCall pushes the settings into the engine, and clears its state when
|
||||||
|
// the feature is switched off — an operator turning it off is entitled to have
|
||||||
|
// it forget the station it was calling, not resume it half an hour later.
|
||||||
|
func (a *App) applyAutoCall() {
|
||||||
|
e := a.autoCallEngine()
|
||||||
|
s := a.autoCallSettings()
|
||||||
|
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 {
|
||||||
|
e.Reset()
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acPeriod, a.acBuf = nil, nil
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAutoCall is the operator's restart after the engine gave up on an
|
||||||
|
// explicit target: it clears every verdict, including the grey list.
|
||||||
|
func (a *App) ResetAutoCall() {
|
||||||
|
a.autoCallEngine().Reset()
|
||||||
|
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.
|
||||||
|
type AutoCallStatus struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// 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:
|
||||||
|
// whichever is typed into, both show it.
|
||||||
|
Only string `json:"only"`
|
||||||
|
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"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
MaxMiss int `json:"max_miss"`
|
||||||
|
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
|
||||||
|
// nothing on purpose looks exactly like one that is broken.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetAutoCallStatus() AutoCallStatus {
|
||||||
|
st := a.autoCallEngine().Status()
|
||||||
|
a.acMu.Lock()
|
||||||
|
reason := a.acReason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
set := a.GetAutoCallSettings()
|
||||||
|
return AutoCallStatus{
|
||||||
|
Enabled: set.Enabled, Only: set.Only,
|
||||||
|
Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
|
||||||
|
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
||||||
|
Greylisted: a.autoCallEngine().Greylisted(),
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emitAutoCall() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "autocall:status", a.GetAutoCallStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeAutoCallTarget adopts the station the operator has just clicked, so a
|
||||||
|
// manual pick gets the same watchdogs as an automatic one — the click is the
|
||||||
|
// choice of station, not a decision to call it for ever.
|
||||||
|
func (a *App) TakeAutoCallTarget(call, band, mode string) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call = strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
if call == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallEngine().Take(autocall.Candidate{
|
||||||
|
Decode: autocall.Decode{Call: call, Band: band, Mode: mode, At: time.Now().UTC(), IsNew: true},
|
||||||
|
Need: a.autoCallNeed(call, band, mode),
|
||||||
|
Watched: a.autoCallWatched(call),
|
||||||
|
})
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The decode stream, cut into periods ───────────────────────────────────
|
||||||
|
|
||||||
|
// acDecode is one decode held until its period is complete.
|
||||||
|
type acDecode struct {
|
||||||
|
d autocall.Decode
|
||||||
|
tx bool // the decode is our own transmission echoed back
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallFeed takes one decode from the UDP loop.
|
||||||
|
//
|
||||||
|
// Decodes arrive one datagram at a time and a decision needs the whole period:
|
||||||
|
// the best station in it, and whether the target was there at all. They are
|
||||||
|
// therefore buffered under the period they belong to, and the period is judged
|
||||||
|
// when the next one starts — or, if the band goes quiet, by the sweeper below,
|
||||||
|
// which is what makes "not decoded for three of its periods" reachable when the
|
||||||
|
// answer is that nothing is being decoded at all.
|
||||||
|
func (a *App) autoCallFeed(d autocall.Decode) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst := d.Instance
|
||||||
|
key := acPeriodKey(d.At, d.TRPeriod)
|
||||||
|
a.acMu.Lock()
|
||||||
|
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.acFed = map[string]time.Time{}
|
||||||
|
}
|
||||||
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||||
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acBuf[inst] = []acDecode{{d: d}}
|
||||||
|
a.acMu.Unlock()
|
||||||
|
a.autoCallJudge(inst, prev, prevAt, prevTR, buf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.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.
|
||||||
|
//
|
||||||
|
// Per receiver, because with two decoders one may fall silent while the other
|
||||||
|
// is busy — and it is the silent one's period that has to close for a missed
|
||||||
|
// period to be counted at all.
|
||||||
|
func (a *App) autoCallSweep() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type due struct {
|
||||||
|
inst, key string
|
||||||
|
at time.Time
|
||||||
|
tr int
|
||||||
|
buf []acDecode
|
||||||
|
}
|
||||||
|
var ready []due
|
||||||
|
a.acMu.Lock()
|
||||||
|
for inst, key := range a.acPeriod {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
if tr <= 0 {
|
||||||
|
tr = 15
|
||||||
|
}
|
||||||
|
// Measured from when the last decode ARRIVED, not from the period it
|
||||||
|
// belongs to: a decode is stamped with the start of its own slot, so
|
||||||
|
// waiting "a slot plus four seconds" from that stamp is waiting until
|
||||||
|
// the middle of the NEXT slot. See acQuiet.
|
||||||
|
if time.Since(a.acFed[inst]) < acQuiet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
|
delete(a.acPeriod, inst)
|
||||||
|
delete(a.acBuf, inst)
|
||||||
|
}
|
||||||
|
a.acMu.Unlock()
|
||||||
|
for _, d := range ready {
|
||||||
|
a.autoCallJudge(d.inst, d.key, d.at, d.tr, d.buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallSilence is the empty period. With the band dead, no decode ever
|
||||||
|
// arrives to close the next one, and the target's absence would never be
|
||||||
|
// counted — so a period with nothing in it is still a period.
|
||||||
|
//
|
||||||
|
// Only for the receiver the target is being called on: an idle second decoder
|
||||||
|
// has no periods to miss.
|
||||||
|
func (a *App) autoCallSilence() {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inst, target := a.autoCallEngine().TargetInstance()
|
||||||
|
if target == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
quiet := a.acPeriod[inst] == "" && time.Since(a.acLastJudge) > 20*time.Second
|
||||||
|
tr := a.acTR[inst]
|
||||||
|
a.acMu.Unlock()
|
||||||
|
if !quiet {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.autoCallJudge(inst, acPeriodKey(now, tr), now, tr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acPeriodKey(at time.Time, trSec int) string {
|
||||||
|
if trSec <= 0 {
|
||||||
|
trSec = 15
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", at.UTC().Unix()/int64(trSec))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallJudge resolves what the log needs from each station in the period,
|
||||||
|
// runs the decision, and carries it out.
|
||||||
|
func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDecode) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acLastJudge = time.Now()
|
||||||
|
a.acMu.Unlock()
|
||||||
|
|
||||||
|
// One status call for the whole period. It reads a cached worked-index, but
|
||||||
|
// it is still per-callsign work and a busy period is thirty of them.
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var q []SpotQuery
|
||||||
|
var uniq []acDecode
|
||||||
|
for _, dd := range buf {
|
||||||
|
k := dd.d.Call + "|" + dd.d.Band + "|" + dd.d.Mode
|
||||||
|
if seen[k] {
|
||||||
|
// The same station twice in one period is one candidate, judged on
|
||||||
|
// its most callable line — the engine's bestOf does that, so both
|
||||||
|
// decodes are kept; only the status lookup is deduplicated.
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
uniq = append(uniq, dd)
|
||||||
|
q = append(q, SpotQuery{Call: dd.d.Call, Band: dd.d.Band, Mode: dd.d.Mode})
|
||||||
|
}
|
||||||
|
status := map[string]SpotStatus{}
|
||||||
|
for _, st := range a.ClusterSpotStatuses(q) {
|
||||||
|
status[st.Call+"|"+st.Band+"|"+st.Mode] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
chase := a.autoCallChase()
|
||||||
|
cands := make([]autocall.Candidate, 0, len(uniq))
|
||||||
|
for _, dd := range uniq {
|
||||||
|
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
||||||
|
c := candidateOf(dd.d, st, chase)
|
||||||
|
c.Watched = a.autoCallWatched(dd.d.Call)
|
||||||
|
c.Hidden = a.autoCallHidden(dd.d.Call)
|
||||||
|
cands = append(cands, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := a.autoCallTX()
|
||||||
|
|
||||||
|
act := a.autoCallEngine().OnPeriod(autocall.Period{
|
||||||
|
Instance: inst, Key: key, At: at, TRPeriod: tr, Decodes: cands, TX: tx,
|
||||||
|
MyCall: a.opCall,
|
||||||
|
})
|
||||||
|
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.
|
||||||
|
func (a *App) autoCallDo(act autocall.Action) {
|
||||||
|
if act.Reason != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = act.Reason
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", act.Reason)
|
||||||
|
}
|
||||||
|
switch act.Kind {
|
||||||
|
case autocall.DoReply:
|
||||||
|
d := act.Decode
|
||||||
|
if err := a.AnswerDecode(d.Instance, d.Ms, d.SNR, d.DT, d.AudioHz, d.ModeRaw, d.MsgRaw, d.LowConf); err != nil {
|
||||||
|
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
||||||
|
}
|
||||||
|
case autocall.DoHalt:
|
||||||
|
// Soft: let the over finish, then stop transmitting. Hard: stop now.
|
||||||
|
// The engine decides — see Action.Soft.
|
||||||
|
if err := a.HaltDecodeTx("", act.Soft); err != nil {
|
||||||
|
applog.Printf("autocall: halt failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if act.Kind != autocall.DoNothing || act.Reason != "" {
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallNoteTX is the attempt counter, fed from the decoder's own status.
|
||||||
|
//
|
||||||
|
// ONCE PER TRANSMIT PERIOD. Status arrives every second and says "transmitting"
|
||||||
|
// throughout the over, so counting each one would spend the whole allowance of
|
||||||
|
// seven calls inside a single fifteen-second slot — the counter has to measure
|
||||||
|
// transmissions, not seconds of carrier.
|
||||||
|
func (a *App) autoCallNoteTX(tx autocall.TXState) {
|
||||||
|
if !a.GetAutoCallSettings().Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
// Per receiver as well as per period: in a split view both decoders report
|
||||||
|
// their own transmissions, and one key for both would let the second one's
|
||||||
|
// carrier swallow the first one's count.
|
||||||
|
key := tx.Instance + "|" + acPeriodKey(time.Now().UTC(), a.acTR[tx.Instance])
|
||||||
|
if a.acTXPeriod == key {
|
||||||
|
a.acMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.acTXPeriod = key
|
||||||
|
a.acMu.Unlock()
|
||||||
|
a.autoCallDo(a.autoCallEngine().NoteTX(tx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallTX is the last transmit state reported, as the engine wants it.
|
||||||
|
func (a *App) autoCallTX() autocall.TXState {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
return a.acTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallSetTX(tx autocall.TXState) {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acTX = tx
|
||||||
|
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
|
||||||
|
// 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 panel and the caller quietly start disagreeing.
|
||||||
|
func autoCallNeedOf(status string) autocall.Need {
|
||||||
|
switch status {
|
||||||
|
case "new":
|
||||||
|
return autocall.NeedDXCC
|
||||||
|
case "new-band-mode", "new-band":
|
||||||
|
// New on both counts is at least a new band, and it is the better catch
|
||||||
|
// of the two — it must not fall below a plain new band.
|
||||||
|
return autocall.NeedBand
|
||||||
|
case "new-mode":
|
||||||
|
return autocall.NeedMode
|
||||||
|
case "new-slot":
|
||||||
|
return autocall.NeedSlot
|
||||||
|
}
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoCallNeed(call, band, mode string) autocall.Need {
|
||||||
|
st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode}})
|
||||||
|
if len(st) == 0 {
|
||||||
|
return autocall.NeedNone
|
||||||
|
}
|
||||||
|
return autoCallNeedOf(st[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallWatched asks the watch list, which is the same list the spot alerts
|
||||||
|
// and the cluster colouring use.
|
||||||
|
func (a *App) autoCallWatched(call string) bool {
|
||||||
|
if a.watchlist == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := a.watchlist.Match(strings.ToUpper(strings.TrimSpace(call)))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// startAutoCall starts the engine's loop, with auto-call OFF.
|
||||||
|
//
|
||||||
|
// It is never on from a stored value. This is the one setting in the program
|
||||||
|
// that puts the station on the air by itself, and the operator who left it on
|
||||||
|
// 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() {
|
||||||
|
a.disarmAutoCall("launch")
|
||||||
|
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() {
|
||||||
|
// 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()
|
||||||
|
for range t.C {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.autoCallSweep()
|
||||||
|
a.autoCallSilence()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-4
@@ -93,14 +93,27 @@ func keepKnownBands(want []string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pskrMaxGrids bounds the receiver squares the broker is asked to filter on.
|
||||||
|
// Each is one subscription, and the traffic follows the area: 2000 km is about
|
||||||
|
// 615 squares, which the broker takes in batches without complaint. The cap is
|
||||||
|
// there so an absurd radius cannot turn into thousands of subscriptions — past
|
||||||
|
// it the nearest squares are kept, and the log says how many.
|
||||||
|
const pskrMaxGrids = 900
|
||||||
|
|
||||||
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
// bandOpenNearKm reads the stored receiver radius, falling back to the default
|
||||||
// for anything unset or out of range. The bounds are the two ways to make the
|
// for anything unset or out of range. The bounds are the two ways to make the
|
||||||
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
// watch useless: below 25 km almost nobody is ever near enough to hear anything,
|
||||||
// and past 1000 km the reports stop being about the operator's own path — which
|
// and past 3000 km a "nearby" receiver is on the far side of a continent, which
|
||||||
// is the entire premise of measuring from a receiver rather than a transmitter.
|
// says nothing about the operator's own path.
|
||||||
|
//
|
||||||
|
// The ceiling was 1000 km, chosen with Europe in mind, where that radius holds a
|
||||||
|
// dozen countries' worth of receivers. It is the wrong number in VK: a station
|
||||||
|
// there can have almost nobody inside 300 km, and the chase list stayed empty
|
||||||
|
// not because nothing was on the air but because nothing was listening close
|
||||||
|
// enough to count.
|
||||||
func bandOpenNearKm(raw string) int {
|
func bandOpenNearKm(raw string) int {
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
if err != nil || n < 25 || n > 1000 {
|
if err != nil || n < 25 || n > 3000 {
|
||||||
return pskr.DefaultNearKm
|
return pskr.DefaultNearKm
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
@@ -194,7 +207,17 @@ func (a *App) startBandOpenFeed() {
|
|||||||
// hundred survived the NearKm test below. One ring of squares — about the
|
// hundred survived the NearKm test below. One ring of squares — about the
|
||||||
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
||||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||||
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
//
|
||||||
|
// The set follows the radius the operator asked for. It was one fixed ring
|
||||||
|
// whatever they set — about 300 km — so raising the radius bought nothing:
|
||||||
|
// the reports that would have satisfied it were never sent to us in the
|
||||||
|
// first place, and an operator in a thinly-populated region who widened the
|
||||||
|
// circle to find some receivers saw no change at all.
|
||||||
|
rxGrids := geo.GridsWithin(a.opLat, a.opLon, float64(s.NearKm), pskrMaxGrids)
|
||||||
|
if len(rxGrids) == 0 {
|
||||||
|
rxGrids = geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||||
|
}
|
||||||
|
applog.Printf("pskr: receiver filter — %d squares within %d km of the station", len(rxGrids), s.NearKm)
|
||||||
|
|
||||||
var onGrid func(call, grid string)
|
var onGrid func(call, grid string)
|
||||||
if chaseGrids {
|
if chaseGrids {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
|
|||||||
id := m[1]
|
id := m[1]
|
||||||
// Handled before the column map: freq takes a numeric path (freq_hz +
|
// Handled before the column map: freq takes a numeric path (freq_hz +
|
||||||
// band together) and the extras live in extras_json.
|
// band together) and the extras live in extras_json.
|
||||||
|
// The integer My-station fields take their own numeric path
|
||||||
|
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
|
||||||
|
// guard was written — which is exactly the drift it exists to catch.
|
||||||
|
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
if id == "freq" || qso.IsBulkEditableExtra(id) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+314
@@ -1,4 +1,318 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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 cluster’s 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 station’s 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-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s 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 MSHV’s two-answers-in-one-line, refuses nearly everything on the air because it read the entity’s “worked” as the station’s, or opens with two misses already counted against a station it has only just picked.",
|
||||||
|
"Switching profile no longer leaves the previous logbook’s 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 page’s 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 record’s 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 d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, 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 — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
|
||||||
|
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
|
||||||
|
"[NEW] Un panneau watchlist docké, qui ne montre que ce qui est EN L’AIR 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 s’y caler, avec le badge NEW DXCC / NEW BAND / NEW SLOT du cluster sur chaque ligne. L’onglet Watchlist est un onglet, et en FT8 on vit sur celui des décodes : une station qu’on avait demandé à surveiller apparaissait sur un écran qu’on ne regardait pas. À activer avec la cloche dans la barre d’outils.",
|
||||||
|
"[NEW] FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — 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 l’auto-call ATTEND (le sablier), tant que rien n’est appelé. Son analyse met une requête d’historique et une période ou deux à se remplir : la lancer quand le DX se libère enfin, c’est la lancer trop tard — l’attente sert précisément à ça.",
|
||||||
|
"[NEW] Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on 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 qu’un écran vide.",
|
||||||
|
"FT decodes : un badge WL rose marque une station de votre watchlist, et l’horloge de période passe au rouge en émission. C’est 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é s’affiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de l’autre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : l’essentiel de ce qu’elle émet s’adresse à d’autres, 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 qu’il 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 l’option 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 n’y est pas, un vrai besoin devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — jamais celle d’un QSO en cours.",
|
||||||
|
"Auto-call : il appelle À TRAVERS un pile-up. Il abandonnait dès que la station appelée répondait à quelqu’un d’autre — or c’est exactement ce que fait un DX avec une file, et le seul moyen d’être le suivant est de continuer à appeler pendant qu’il travaille les autres. Toujours borné par les compteurs d’appels et de ratés, et une station en plein échange n’est 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é — c’est 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 n’est plus appelée jusqu’à ce que l’auto-call soit désactivé puis réactivé. Il dit ce qu’il attend, en affichant à côté du bouton Auto la station voulue qui travaille quelqu’un d’autre. Et il peut journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur l’air et pourquoi chaque station a été refusée.",
|
||||||
|
"Auto-call : série de corrections issues du trafic. Il ne coupe plus votre 73, n’appelle 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 qu’il prenait le « worked » de l’entité 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 d’un profil au log vide, tous les décodes et spots restaient marqués NEW jusqu’au redémarrage : l’index des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
|
||||||
|
"Chase new, corrections : l’en-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 d’anciens verdicts à l’écran.",
|
||||||
|
"Panneau PSK Reporter, corrections : l’historique 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 n’affiche plus une page vide ; l’offset 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 l’adresse et le locator du Panama d’où la station émet, et OpsLog les jetait — la règle qui écarte l’adresse personnelle d’un indicatif portable s’appliquait aussi aux fiches qui décrivent l’opération elle-même. C’est 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",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Help menu: “Join the Discord” opens the OpsLog Discord server in your browser.",
|
||||||
|
"Icom console: a tick box makes the band buttons recall the radio's own band stacking registers — where you last were on that band, in the mode you were in. Press the same band again to step through its three registers, exactly as the radio's band key does. A band the register cannot be read for still sends the fixed frequency.",
|
||||||
|
"Shared CAT: a frequency or mode just commanded is reported back at once instead of on the next poll, and a mode the radio is already in is no longer re-sent. WSJT-X waits for that readback before it believes the band changed — over a network CI-V link that wait was several seconds.",
|
||||||
|
"Chase new / band openings: the receiver radius goes up to 3000 km, and the PSK Reporter subscription now follows it. Widening the circle used to change nothing, because the reports it would have accepted were never sent to us. 300 km holds no receivers at all in much of VK, ZL or North America.",
|
||||||
|
"The radius is also reachable from the Chase new option itself, rather than only from inside the band-opening watch.",
|
||||||
|
"New PSK Reporter panel beside the FT decodes, which can be hidden: for the station you are calling, has he decoded YOU and how long ago, who near you he is hearing, who near him heard you, how many stations he is working through, and where his receive passband is free. Follows the station you click or call. Off by default — Settings → DXHunter/spots. The narrow feed is a few messages a second; a whole-band option is there for a fast machine.",
|
||||||
|
"FT Map: the 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 or ZL that is most of them, each one ending nowhere while its own marker sat on the far coast. A path now leaves one edge and re-enters at the other, at the latitude it left.",
|
||||||
|
"NEW — Auto-call: OpsLog can answer FT8/FT4 decodes on its own. It picks the best station on the air by what the log still needs — a watched callsign outranking the same need from anybody else — and, above all, it knows when to stop: 7 calls (15 for a watched callsign), 3 of the station’s own transmit periods with no decode of it, a four-minute backstop, and three series per station for a whole session. A station in the middle of a QSO with somebody else is never called, because it cannot answer. Every decision is written to the log with its reason, the toolbar button shows the target and the count as it goes, and Halt stops it. OFF by default — Settings → DXHunter/spots. It keys your transmitter without asking, so read that panel’s warning before switching it on, and switch DXHunter’s own auto-call off: two programs answering decodes from one shack transmit over each other.",
|
||||||
|
"PSK Reporter panel: the window is ten minutes, and the history query now fills BOTH directions when the target changes. Side by side with DXHunter on the same station at the same moment it showed 18 decodes against 27, and a co-area station missing — five minutes catches about one upload cycle per reporting station, and most of them report every five.",
|
||||||
|
"Auto-call: “Chase only” takes SEVERAL callsigns, separated by spaces or commas. Nothing off the list is called, the priority ladder still orders the ones on it, and working one leaves the others callable.",
|
||||||
|
"Two new themes on DXHunter’s palette: “DXHunter”, its slate and its blue exactly, and “DXHunter orange”, the same slate with OpsLog’s own orange accent. Both carry its status colours (emerald, amber, cyan, red), so a green number means the same thing in either window. Settings → General → Theme.",
|
||||||
|
"The auto-call chase list is also in the decodes toolbar, beside the Auto button: naming the station you are waiting for is done while watching the band, not in a settings tree. It is the same setting as the one in Preferences — type into either and both show it.",
|
||||||
|
"FT decodes: one click SELECTS a station — it fills the entry and points the panels at it, like a cluster spot — and a double click calls it. A single click used to hand the decode straight to WSJT-X as a reply, so brushing a row while reading the band started transmitting.",
|
||||||
|
"Auto-call with two decoders running: while a station is being called, a period from the other receiver is not looked at. It cannot start a second QSO over the one in progress however good the station it hears, its periods count no missed periods against a target that was never on its band, and its transmissions are not counted as calls to that target.",
|
||||||
|
"Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced (the difference that shows with the two windows side by side), badges at its size, a check or a warning triangle at the head of each spot line, and frequencies as 7.056 rather than 7.0560.",
|
||||||
|
"A decoder is named by what it IS, not by what it announces: Nexus sends its packets as “Tempo”, the engine inside it, and OpsLog showed a program the operator had never heard of. The id itself is untouched — it is what a reply, a halt and the auto-call are routed by.",
|
||||||
|
"WSJT-X colouring can grey out a station already worked on this band AND in this mode — the duplicate. Its own switch under the highlight option, off by default: the other three verdicts pick out a handful of decodes, this one can match most of a period on a well-filled log. Grey rather than a colour, because it says the opposite of the others.",
|
||||||
|
"Contest watchlist (Settings → DXHunter): beside the auto-add pattern there is now a list of callsigns, one per line. A pattern collects a fleet that shares a string — TM29WWA, HB9WWA, F4WWA/P — but not the station taking part under a callsign that says nothing about the event. Named in the list, it joins the contest watchlist the moment it is spotted."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Menu Aide : « Rejoindre le Discord » ouvre le serveur Discord d’OpsLog dans votre navigateur.",
|
||||||
|
"Console Icom : une case à cocher fait rappeler aux boutons de bande les registres de bande de la radio — là où vous étiez la dernière fois sur cette bande, dans le mode où vous étiez. Réappuyez sur la même bande pour parcourir ses trois registres, comme le fait la touche de bande du poste. Une bande dont le registre est illisible envoie toujours la fréquence fixe.",
|
||||||
|
"CAT partagé : une fréquence ou un mode qu’on vient de commander est renvoyé immédiatement, sans attendre le sondage suivant, et un mode que la radio a déjà n’est plus réémis. WSJT-X attend cette relecture avant de croire au changement de bande — sur une liaison CI-V réseau, cette attente durait plusieurs secondes.",
|
||||||
|
"Chase new / ouvertures : le rayon des récepteurs monte à 3000 km, et l’abonnement PSK Reporter le suit désormais. Élargir le cercle ne changeait rien, car les reports qu’il aurait acceptés ne nous étaient jamais envoyés. 300 km ne contient aucun récepteur dans une bonne partie de VK, ZL ou d’Amérique du Nord.",
|
||||||
|
"Ce rayon est aussi accessible depuis l’option Chase new elle-même, et non plus seulement depuis la veille d’ouvertures.",
|
||||||
|
"Nouveau panneau PSK Reporter à côté des décodages FTx, masquable : pour la station que vous appelez, vous a-t-il décodé et depuis combien de temps, qui entend-il près de chez vous, qui près de lui vous a entendu, combien de stations défilent chez lui, et où son passe-bande est libre. Il suit la station que vous cliquez ou appelez. Désactivé par défaut — Réglages → DXHunter/spots. Le flux étroit ne coûte que quelques messages par seconde ; une option « toute la bande » existe pour une machine rapide.",
|
||||||
|
"FT Map : les arcs ne partent plus hors de la carte. La carte n’affiche qu’un seul monde, et un chemin franchissant l’antiméridien était tracé au-delà de 180°, dans le vide à côté — depuis VK ou ZL c’est la majorité d’entre eux, chacun finissant nulle part alors que son propre marqueur était sur la côte opposée. Un chemin sort désormais par un bord et revient par l’autre, à la latitude où il est sorti.",
|
||||||
|
"NOUVEAU — Appel automatique : OpsLog peut répondre seul aux décodages FT8/FT4. Il choisit la meilleure station en fonction de ce qui manque au log — un indicatif surveillé passant devant le même besoin chez un autre — et surtout il sait s’arrêter : 7 appels (15 pour un indicatif surveillé), 3 périodes d’émission de la station sans la décoder, un butoir de quatre minutes, et trois séries par station pour toute une session. Une station en plein QSO avec quelqu’un d’autre n’est jamais appelée : elle ne peut pas répondre. Chaque décision est écrite dans le journal avec sa raison, le bouton de la barre affiche la cible et le décompte en direct, et Stop l’interrompt. DÉSACTIVÉ par défaut — Réglages → DXHunter/spots. Il met votre émetteur en marche sans vous demander : lisez l’avertissement du panneau avant de l’activer, et coupez l’appel automatique de DXHunter — deux programmes qui répondent aux décodages du même shack s’émettent dessus.",
|
||||||
|
"Panneau PSK Reporter : la fenêtre passe à dix minutes, et la requête d’historique remplit désormais les DEUX sens au changement de cible. Côte à côte avec DXHunter sur la même station au même instant, il affichait 18 décodages contre 27, et une station de la région manquait — cinq minutes ne captent qu’un cycle d’envoi par station, et la plupart n’envoient que toutes les cinq minutes.",
|
||||||
|
"Appel automatique : « Chasser uniquement » accepte PLUSIEURS indicatifs, séparés par des espaces ou des virgules. Rien hors de la liste n’est appelé, l’échelle de priorité départage ceux qui y sont, et en travailler un laisse les autres appelables.",
|
||||||
|
"Deux nouveaux thèmes sur la palette de DXHunter : « DXHunter », son ardoise et son bleu à l’identique, et « DXHunter orange », la même ardoise avec l’orange d’OpsLog. Les deux reprennent ses couleurs d’état (émeraude, ambre, cyan, rouge) : un nombre vert veut dire la même chose dans les deux fenêtres. Réglages → Général → Thème.",
|
||||||
|
"La liste de chasse de l’appel automatique est aussi dans la barre des décodages, à côté du bouton Auto : nommer la station qu’on attend se fait en regardant la bande, pas dans un arbre de réglages. C’est le même réglage que dans les Préférences — saisi dans l’un, il apparaît dans l’autre.",
|
||||||
|
"Décodages FT : un clic SÉLECTIONNE une station — il remplit la saisie et y pointe les panneaux, comme un spot du cluster — et un double-clic l’appelle. Un simple clic passait le décodage directement à WSJT-X en réponse : frôler une ligne en lisant la bande déclenchait une émission.",
|
||||||
|
"Appel automatique avec deux décodeurs : pendant qu’une station est appelée, une période de l’autre récepteur n’est pas examinée. Il ne peut pas ouvrir un second QSO par-dessus celui en cours, si bonne que soit la station qu’il entend ; ses périodes ne comptent aucune période manquée contre une cible qui n’a jamais été sur sa bande, et ses émissions ne comptent pas comme des appels vers elle.",
|
||||||
|
"Watchlist : dessinée comme DXHunter la dessine — l’indicatif dans la police de l’interface plutôt qu’en chasse fixe (la différence qui saute aux yeux avec les deux fenêtres côte à côte), pastilles à sa taille, coche ou triangle d’alerte en tête de chaque ligne de spot, et fréquences en 7.056 plutôt que 7.0560.",
|
||||||
|
"Un décodeur est nommé par ce qu’il EST, non par ce qu’il annonce : Nexus envoie ses paquets sous le nom « Tempo », le moteur qu’il embarque, et OpsLog affichait un programme inconnu de l’opérateur. L’identifiant lui-même n’est pas touché : c’est par lui que passent une réponse, un Stop et l’appel automatique.",
|
||||||
|
"Coloration WSJT-X : possibilité de griser une station déjà travaillée sur cette bande ET dans ce mode — le doublon. Sa propre case sous l’option de coloration, désactivée par défaut : les trois autres verdicts désignent quelques décodages, celui-ci peut concerner la majorité d’une période sur un log bien rempli. En gris et non en couleur, car il dit l’inverse des autres.",
|
||||||
|
"Watchlist contest (Réglages → DXHunter) : à côté du motif d’ajout automatique, une liste d’indicatifs, un par ligne. Un motif attrape une flotte qui partage une chaîne — TM29WWA, HB9WWA, F4WWA/P — mais pas la station engagée sous un indicatif qui ne dit rien de l’événement. Nommée dans la liste, elle rejoint la watchlist contest dès qu’elle est spottée."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.11",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Country resolution with the ClubLog file enabled: retired prefixes are recognised again. cty.dat describes the world as it is TODAY — Niue moved to E6, so ZK2 reverted to New Zealand there, and every ZK2 contact ever made was silently relabelled New Zealand, taking a confirmed entity out of the operator’s DXCC with it. ZK1 lost the Cook Islands the same way. ClubLog’s prefix table still knows both and is now consulted whenever no per-callsign exception applies, instead of only for callsigns that happened to have one. It never overrules an exact “=CALLSIGN” entry in cty.dat, and where it has no answer cty.dat still decides. Reprocess an affected import with Update from ClubLog.",
|
||||||
|
"CQ and ITU zones now come from the callbook when it has them. cty.dat carries ONE representative pair per entity, and OpsLog was stamping it over QRZ.com’s per-station answer — so every Asiatic Russia contact was logged CQ 17 / ITU 30, whatever the operator’s real zone (RU0LL is 19/34, UA0SDX 18/32). That is a WAZ credit for a zone never worked. The entity still comes from cty.dat, which is the authority on what a callsign IS; a zone says where the station SITS, and only the callbook knows that within a country eight CQ zones wide. Where the callbook is silent, cty.dat fills as before.",
|
||||||
|
"Callsign cache: a lookup is now stored as the callbook returned it, and the country file is applied when it is read. A value OpsLog derived can no longer come back later looking like something the page said — which is how the wrong zones outlived their fix — and a country-file update now reaches rows already cached.",
|
||||||
|
"“Send to Cloudlog / Wavelog” joins the right-click upload menu. It was the one configured service missing from it: Cloudlog keeps no per-QSO sent status — deliberately, since it dedupes server-side — and the menu had been built around that status. An explicit selection needs no status to be safe, which is exactly why the absence stops mattering here.",
|
||||||
|
"HAMLOG.online uploads are closed. The site no longer issues API keys and its upload API takes nothing else, so the auto-upload switch and the “Send to” entry armed something that could only fail. Everything else stays: their confirmations still import from a file (which never needed a key), and the sent/received state already in your log remains readable, filterable and bulk-editable. The upload code is kept whole against the day keys come back.",
|
||||||
|
"Yaesu console: the power slider no longer springs back to 100 W on a 200 W radio. The console already knew an FTDX101MP could do 200 — that is what it drew the slider to — but the command that sets it was clamped to 100, so the rig was politely given half of what was asked for and the next poll showed it. FTDX101MP, FT-DX5000 and FTDX9000 reach 200 W now, and a rig reporting more than expected is still believed.",
|
||||||
|
"Chase new: clicking a row now does what clicking a cluster spot does — including telling WSJT-X, JTDX or MSHV to change mode, so picking an FT4 station while the decoder sits in FT8 actually moves it. It also brings across the mode and its RST preset, and any park or summit reference. It used to do a hand-picked half of that, which left the one thing the window exists for — jumping onto a station — decoding the wrong mode.",
|
||||||
|
"FT decodes: JTDX on FT4 no longer reads as Q65. A decode carries a one-character mode marker, and the two programs disagree about “:” — Q65 in WSJT-X, FT4 in JTDX — so the character alone cannot answer, and the wrong answer poisoned every verdict behind it: new mode, new slot, the mode filter. The sending program’s own status names the mode in full and now settles it; the markers both forks agree on are untouched."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Résolution des entités avec le fichier ClubLog activé : les préfixes retirés sont de nouveau reconnus. cty.dat décrit le monde tel qu’il est AUJOURD’HUI — Niue est passée en E6, donc ZK2 y est revenu à la Nouvelle-Zélande, et tous les contacts ZK2 jamais faits étaient silencieusement réétiquetés Nouvelle-Zélande, emportant une entité confirmée hors du DXCC de l’opérateur. ZK1 perdait les Cook du Sud de la même façon. La table de préfixes ClubLog connaît toujours les deux : elle est désormais consultée dès qu’aucune exception par indicatif ne s’applique, et non plus seulement pour les indicatifs qui en avaient une. Elle ne prime jamais sur une entrée exacte « =INDICATIF » de cty.dat, et là où elle n’a pas de réponse c’est cty.dat qui tranche. Repassez un import concerné par « Mettre à jour depuis ClubLog ».",
|
||||||
|
"Les zones CQ et ITU proviennent désormais du callbook quand il les connaît. cty.dat ne porte QU’UNE paire représentative par entité, et OpsLog l’imposait par-dessus la réponse par station de QRZ.com — tout contact avec la Russie asiatique était donc enregistré en CQ 17 / ITU 30, quelle que soit la zone réelle (RU0LL est en 19/34, UA0SDX en 18/32). C’est un crédit WAZ pour une zone jamais travaillée. L’entité vient toujours de cty.dat, qui fait autorité sur ce qu’un indicatif EST ; une zone dit où la station SE TROUVE, et seul le callbook le sait dans un pays large de huit zones CQ. Là où le callbook se tait, cty.dat comble comme avant.",
|
||||||
|
"Cache des indicatifs : une recherche est désormais stockée telle que le callbook l’a rendue, le fichier pays étant appliqué à la lecture. Une valeur déduite par OpsLog ne peut plus revenir plus tard avec l’apparence de ce qu’a dit la page — c’est ainsi que les mauvaises zones survivaient à leur correctif — et une mise à jour du fichier pays atteint maintenant les fiches déjà en cache.",
|
||||||
|
"« Envoyer vers Cloudlog / Wavelog » rejoint le menu d’envoi du clic droit. C’était le seul service configuré qui y manquait : Cloudlog ne conserve aucun statut d’envoi par QSO — volontairement, puisqu’il dédoublonne côté serveur — et le menu était construit autour de ce statut. Une sélection explicite n’a besoin d’aucun statut pour être sûre : c’est précisément pourquoi cette absence cesse de compter ici.",
|
||||||
|
"Les envois vers HAMLOG.online sont fermés. Le site ne délivre plus de clé API et son interface d’envoi n’accepte rien d’autre : la case d’envoi automatique et l’entrée « Envoyer vers » armaient donc quelque chose qui ne pouvait qu’échouer. Tout le reste demeure : leurs confirmations s’importent toujours depuis un fichier (ce qui n’a jamais demandé de clé), et l’état envoyé/reçu déjà présent dans votre log reste lisible, filtrable et modifiable en masse. Le code d’envoi est conservé intact pour le jour où les clés reviendraient.",
|
||||||
|
"Console Yaesu : le curseur de puissance ne revient plus à 100 W sur une radio de 200 W. La console savait déjà qu’un FTDX101MP peut sortir 200 — c’est à cela qu’elle dimensionnait son curseur — mais la commande d’envoi était bornée à 100 : le poste recevait donc poliment la moitié de ce qu’on lui demandait, et le sondage suivant l’affichait. FTDX101MP, FT-DX5000 et FTDX9000 atteignent désormais 200 W, et une radio qui annonce plus que prévu reste crue sur parole.",
|
||||||
|
"Chase new : cliquer une ligne fait désormais ce que fait un clic sur un spot du cluster — y compris demander à WSJT-X, JTDX ou MSHV de changer de mode, si bien que choisir une station FT4 alors que le décodeur est en FT8 l’y amène vraiment. Le mode et son RST par défaut suivent aussi, de même que toute référence de parc ou de sommet. La fenêtre n’en faisait qu’une moitié choisie à la main, ce qui laissait la seule chose pour laquelle elle existe — sauter sur une station — décoder dans le mauvais mode.",
|
||||||
|
"FT decodes : JTDX en FT4 ne s’affiche plus en Q65. Un décodage porte un marqueur de mode d’un seul caractère, et les deux logiciels ne s’accordent pas sur « : » — Q65 pour WSJT-X, FT4 pour JTDX : le caractère seul ne peut donc pas trancher, et sa mauvaise réponse contaminait tout ce qui en découle — nouveau mode, nouveau slot, filtre de mode. Le statut envoyé par le logiciel lui-même nomme le mode en entier et tranche désormais ; les marqueurs sur lesquels les deux s’accordent ne changent pas."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.10",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
|
||||||
|
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NG’s firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez.",
|
||||||
|
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base d’Arduino — le firmware K3NG, la famille ERC — REDÉMARRE à l’ouverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels d’un échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
|
||||||
|
"Voice keyer with CAT keying: an option saying the keyer’s audio arrives on the radio’s DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rig’s own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — la signature d’une liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait s’en apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte qu’une ligne à ignorer.",
|
||||||
|
"Voice keyer avec PTT CAT : une option indiquant que l’audio du keyer arrive sur l’entrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 l’ACC2/USB — si bien qu’un keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The band matrix’s DIG row is now a rotation: click it and it answers for FT8, then FT4, then each digital mode your mode list holds — in YOUR order — then back to DIG. One row per digital mode would be the honest layout and there is no height for it beside the other widgets, so the row keeps its place and changes what it says. The label column is sized once for the longest mode it can show, so the matrix never shifts as the rotation comes round to RTTY.",
|
||||||
|
"Icom console: LSB and USB are separate buttons and can finally be commanded by name — the single SSB button resolved the sideband from the band, so there was no way to ask an IC-7300 for USB on 40 m. A rig reporting the folded “SSB” still lights the side its frequency implies.",
|
||||||
|
"Icom console: a 60 m band button, and the antenna and PSK controls only appear on radios that have them. An IC-7300 has one antenna socket and no native PSK mode, so ANT1/ANT2 could only ever disagree with its front panel and the PSK button was dead furniture.",
|
||||||
|
"Icom console: the mic gain is no longer hidden outside phone modes — on USB-D it still sets what the radio transmits at, and an operator who lives in FT8 had none at all.",
|
||||||
|
"Icom CI-V: address 0x00 can be chosen. It was silently refused and replaced by the IC-7610 default, with no way to say what was meant."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La ligne DIG de la matrice devient une rotation : un clic et elle répond pour FT8, puis FT4, puis chaque mode numérique de votre liste — dans VOTRE ordre — puis retour à DIG. Une ligne par mode numérique serait la mise en page honnête et la hauteur manque à côté des autres widgets : la ligne garde donc sa place et change ce qu’elle dit. La colonne des libellés est dimensionnée une fois pour le plus long mode qu’elle peut afficher : la matrice ne bouge donc plus quand la rotation arrive sur RTTY.",
|
||||||
|
"Console Icom : LSB et USB sont deux boutons distincts et peuvent enfin être demandés par leur nom — le bouton SSB unique déduisait la bande latérale de la fréquence, impossible donc de demander l’USB à un IC-7300 sur 40 m. Une radio qui annonce le « SSB » générique allume malgré tout le côté que sa fréquence implique.",
|
||||||
|
"Console Icom : un bouton de bande 60 m, et les commandes antenne et PSK n’apparaissent que sur les radios qui en disposent. Un IC-7300 n’a qu’une prise d’antenne et pas de mode PSK natif : ANT1/ANT2 ne pouvait que contredire sa face avant, et le bouton PSK était un meuble mort.",
|
||||||
|
"Console Icom : le gain micro n’est plus masqué hors des modes phonie — en USB-D il règle toujours le niveau d’émission, et un opérateur qui vit en FT8 n’en avait aucun.",
|
||||||
|
"CI-V Icom : l’adresse 0x00 peut être choisie. Elle était refusée en silence et remplacée par le défaut IC-7610, sans moyen de dire ce que l’on voulait."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.7",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX Cluster: a disconnected server keeps its pill, so it can be reconnected — disconnecting one used to make it vanish along with the only way back.",
|
||||||
|
"HamQTH: an “Upload the whole log” button in the QSL Manager — one file instead of one request per QSO, so a first sync takes seconds rather than the better part of an hour. It REPLACES the log held on HamQTH (the site has no partial upload), so it asks first, is scoped to the callsign this profile uploads as, and compresses a large log to stay under the 20 MB limit.",
|
||||||
|
"Outbound ADIF (forwarding a logged QSO to another logger such as Log4OM): the receive side is filled in when the contact was not split, so BAND_RX and FREQ_RX are present. The importer already did this; the logging paths did not, so what a QSO carried depended on which door it came in through.",
|
||||||
|
"HamQTH joins the places the other services already were: two Recent-QSOs columns (sent status and date), the QSO filter, and bulk edit — the last one so a log uploaded to HamQTH by hand can be marked as sent instead of being offered for upload all over again.",
|
||||||
|
"HamQTH whole-log upload: it reports itself in the console like every other action — the callsign it is scoped to, how many of the logbook’s QSOs that leaves, the file size, and HamQTH’s own reply — and says plainly that HamQTH imports the file in the background and e-mails any ADIF errors, so a site count that lags or stops short is explained rather than mysterious.",
|
||||||
|
"QSO editor: correcting a frequency now moves its band with it, TX and RX — a QSO fixed to 7.1 MHz no longer stays filed on 20m. The band is only touched when the frequency lands in a known allocation, so a half-typed number never blanks it.",
|
||||||
|
"CAT: an option to put the radio in USB for digital modes (Settings → CAT), for every backend. Clicking an FT8 spot on a rig whose CAT layer resolves “digital” to RTTY/FSK — OmniRig does, per rig file — landed the operator in FSK, which cannot pass FT8 at all. The QSO is still logged as FT8: only the radio changes.",
|
||||||
|
"Fixed: a “worked but not confirmed” badge turned solid again after the next QSO, so an entity worked on a band still read NEW BAND as if it had never been worked there. Four hand-written copies of the same status mapping had drifted apart — the two that re-fetch dropped the unconfirmed flags, and none of them ever carried the grid one. There is one mapping now.",
|
||||||
|
"The Chase switches (POTA, US counties, prefixes, grids) now hold in FT decodes and in Chase new, not only in the DX Cluster — they say what you hunt, not which panel is open. Unchecked, their badges and filter chips disappear from all three and only the entity verdicts remain: NEW DXCC, band, mode, slot.",
|
||||||
|
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunter’s ideas will land beside them.",
|
||||||
|
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
|
||||||
|
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
|
||||||
|
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
|
||||||
|
"Panadapter spots now carry SmartSDR’s priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
|
||||||
|
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen d’y revenir.",
|
||||||
|
"HamQTH : un bouton « Envoyer tout le log » dans le QSL Manager — un seul fichier au lieu d’une requête par QSO, une première synchro passe de près d’une heure à quelques secondes. Il REMPLACE le log stocké sur HamQTH (le site n’a pas d’envoi partiel) : il demande donc confirmation, se limite à l’indicatif du profil et compresse un gros log pour rester sous la limite de 20 Mo.",
|
||||||
|
"ADIF sortant (transfert d’un QSO vers un autre log, Log4OM par exemple) : le côté réception est renseigné quand le contact n’était pas en split, donc BAND_RX et FREQ_RX sont présents. L’import le faisait déjà, pas les chemins de log — ce qu’un QSO transportait dépendait donc de la porte par laquelle il était entré.",
|
||||||
|
"HamQTH rejoint les endroits où les autres services étaient déjà : deux colonnes dans les QSO récents (statut et date d’envoi), le filtre de QSO et l’édition groupée — cette dernière pour qu’un log envoyé à la main sur HamQTH puisse être marqué comme envoyé au lieu d’être reproposé à l’envoi.",
|
||||||
|
"Envoi du log complet HamQTH : il rend compte dans la console comme toutes les autres actions — l’indicatif retenu, combien de QSO du journal cela représente, la taille du fichier et la réponse de HamQTH — et indique clairement que HamQTH importe le fichier en arrière-plan et envoie les erreurs ADIF par e-mail : un compteur en retard ou incomplet sur le site est ainsi expliqué au lieu d’être mystérieux.",
|
||||||
|
"Éditeur de QSO : corriger une fréquence déplace désormais sa bande avec elle, TX comme RX — un QSO corrigé à 7,1 MHz ne reste plus classé en 20m. La bande n’est touchée que si la fréquence tombe dans une allocation connue : un nombre à moitié tapé ne l’efface jamais.",
|
||||||
|
"CAT : une option pour mettre la radio en USB sur les modes numériques (Réglages → CAT), pour tous les backends. Cliquer un spot FT8 sur un poste dont la couche CAT traduit « numérique » par RTTY/FSK — c’est le cas d’OmniRig, selon le fichier radio — faisait basculer l’opérateur en FSK, incapable de passer du FT8. Le QSO reste enregistré en FT8 : seule la radio change.",
|
||||||
|
"Corrigé : un badge « contacté mais non confirmé » redevenait plein dès le QSO suivant, si bien qu’une entité contactée sur une bande affichait NEW BAND comme si elle ne l’avait jamais été. Quatre copies écrites à la main de la même conversion de statut avaient divergé — les deux qui rafraîchissent perdaient les drapeaux « non confirmé », et aucune ne transportait celui des locators. Il n’y en a plus qu’une.",
|
||||||
|
"Les cases Chasse (POTA, comtés US, préfixes, locators) s’appliquent désormais aux FT decodes et à Chase new, plus seulement au DX Cluster — elles disent ce que vous chassez, pas quel panneau est ouvert. Décochées, leurs badges et leurs puces de filtre disparaissent des trois écrans et il ne reste que les verdicts d’entité : NOUVEAU DXCC, bande, mode, slot.",
|
||||||
|
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom d’un seul ; d’autres idées de DXHunter viendront s’y ajouter.",
|
||||||
|
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
|
||||||
|
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici l’instant où l’onglet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
|
||||||
|
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même d’avoir entendu quoi que ce soit.",
|
||||||
|
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée d’abord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et n’en dessine qu’un — elle choisit par priorité, si bien qu’un nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog d’avoir dit laquelle méritait la place.",
|
||||||
|
"Une station déjà contactée sur la MÊME bande et le même mode n’annonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de l’entité, là où le besoin peut réellement être comblé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.",
|
||||||
|
"HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).",
|
||||||
|
"Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.",
|
||||||
|
"LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county.",
|
||||||
|
"DX Cluster: two new chase switches — Chase US counties and Chase new prefixes — and unchecking Chase new grids now also withdraws the NEW GRID badge. Each switch removes its badge from the spots AND its chip from the status filters, like Chase POTA always did.",
|
||||||
|
"Confirmations: a HamQTH row — sent only, defaulting to R (to upload), since HamQTH publishes no confirmations to receive. Setting such a default no longer disables the auto-upload it was meant to arm (it also affected HAMLOG.online).",
|
||||||
|
"New DXpeditions tab (Tools): the announced operations from NG3K’s ADXO next to the DX-World news feed. Every announcement is judged against YOUR log and carries one badge — NEW DXCC, NEW BAND, NEW SLOT… — with an “only what I need” filter, and one click adds its callsigns to the watchlist. The news headlines carry the same Watch button, over the callsigns mined out of their titles.",
|
||||||
|
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
|
||||||
|
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
|
||||||
|
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
|
||||||
|
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
|
||||||
|
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and − buttons keep the zoom.",
|
||||||
|
"DX Cluster: the server pills drop the CONNECTED/DISCONNECTED word — the colour already said it — and clicking one now connects or disconnects that server on its own, without opening Settings. State, retries, address and last error moved into the tooltip."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
|
||||||
|
"Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).",
|
||||||
|
"Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.",
|
||||||
|
"LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté.",
|
||||||
|
"DX Cluster : deux nouvelles cases — Chasser les comtés US et Chasser les nouveaux préfixes — et décocher Chasser les nouveaux locators retire désormais aussi le badge NEW GRID. Chaque case enlève son badge des spots ET sa puce des filtres de statut, comme Chase POTA le faisait déjà.",
|
||||||
|
"Confirmations : une ligne HamQTH — envoi seulement, à R (à envoyer) par défaut, HamQTH ne publiant aucune confirmation à recevoir. Définir un tel défaut ne désactive plus l’upload automatique qu’il était censé armer (cela touchait aussi HAMLOG.online).",
|
||||||
|
"Nouvel onglet DXpéditions (Outils) : les opérations annoncées par l’ADXO de NG3K à côté du fil d’actualités DX-World. Chaque annonce est jugée sur VOTRE log et porte un badge — NOUVEAU DXCC, NOUVELLE BANDE, NOUVEAU SLOT… — avec un filtre « seulement ce qu’il me manque », et un clic ajoute ses indicatifs à la watchlist. Les actualités portent le même bouton Surveiller, sur les indicatifs extraits de leurs titres.",
|
||||||
|
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu d’un message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou l’onglet DXpéditions, où ce message n’était jamais vu.",
|
||||||
|
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
|
||||||
|
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
|
||||||
|
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé d’une session à l’autre.",
|
||||||
|
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et − gardent le zoom.",
|
||||||
|
"DX Cluster : les pastilles de serveur perdent le mot CONNECTED/DISCONNECTED — la couleur le disait déjà — et cliquer sur l’une connecte ou déconnecte ce serveur seul, sans passer par les réglages. État, tentatives, adresse et dernière erreur passent dans l’infobulle."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
|
||||||
|
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
|
||||||
|
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log.",
|
||||||
|
"FT Map / Grid squares: the map no longer floats above the menus and the Preferences dialog.",
|
||||||
|
"KPA500: saving ANY settings page no longer power-cycles the amplifier. A save rebuilt every amplifier connection, and closing the COM port drops DTR/RTS — the KPA500’s power switch. An unchanged amplifier now keeps its running connection across saves.",
|
||||||
|
"Column picker: columns are listed alphabetically inside each group.",
|
||||||
|
"Confirmations: a “Club Log received” default for new QSOs, set to No out of the box — the match download flips it to Y."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
|
||||||
|
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP.",
|
||||||
|
"Corrigé : l’application pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne s’applique pas, et un échec de démarrage est écrit dans le log.",
|
||||||
|
"FT Map / Grid squares : la carte ne passe plus au-dessus des menus et de la fenêtre Préférences.",
|
||||||
|
"KPA500 : sauvegarder n’importe quelle page des réglages n’éteint plus l’ampli. Une sauvegarde reconstruisait chaque connexion d’ampli, et fermer le port COM relâche DTR/RTS — l’interrupteur d’alimentation du KPA500. Un ampli inchangé garde désormais sa connexion à travers les sauvegardes.",
|
||||||
|
"Sélecteur de colonnes : les colonnes sont triées alphabétiquement dans chaque groupe.",
|
||||||
|
"Confirmations : un défaut « Club Log reçu » pour les nouveaux QSO, à Non par défaut — le téléchargement des matches le passe à Y."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
|
||||||
|
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
|
||||||
|
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grid’s own Chase selector folds into it.",
|
||||||
|
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
|
||||||
|
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
|
||||||
|
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
|
||||||
|
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
|
||||||
|
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté s’allume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
|
||||||
|
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins s’affichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
|
||||||
|
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
|
||||||
|
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
|
||||||
|
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
|
||||||
|
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
|
||||||
|
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first.",
|
||||||
|
"Voice keyer: twelve message slots (F1–F12) instead of six.",
|
||||||
|
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
|
||||||
|
"Voice keyer: a delete button per message — removes the recording and clears the label."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"KPA500 : l’ampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est plus traité comme un lien mort, et le baud se choisit dans une liste.",
|
||||||
|
"FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.",
|
||||||
|
"Manipulateur vocal : douze messages (F1–F12) au lieu de six.",
|
||||||
|
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
|
||||||
|
"Manipulateur vocal : un bouton supprimer par message — efface l’enregistrement et le libellé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.27.2",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.",
|
||||||
|
"Elecraft console: the power meter reads in real watts. The K3’s bargraph is relative to a range that flips at 12 W — calibrated against a real radio’s full table, the PC setting picks the range and the bar converts to watts.",
|
||||||
|
"Watchlist: a visual pass toward DXHunter’s look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.",
|
||||||
|
"WSJT-X / JTDX: OpsLog can highlight decodes in the decoder’s own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full.",
|
||||||
|
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
|
||||||
|
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the map’s ±85°, the path still draws.",
|
||||||
|
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoder’s mode too — option in Settings → Connections, on by default."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.",
|
||||||
|
"Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète d’une vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.",
|
||||||
|
"Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.",
|
||||||
|
"WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein.",
|
||||||
|
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
|
||||||
|
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
|
||||||
|
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.1",
|
"version": "0.27.1",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/clublog"
|
||||||
|
"hamlog/internal/dxcc"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reported from a real import: every ZK2 contact came back as New Zealand and a
|
||||||
|
// confirmed entity — Niue — disappeared from the operator's DXCC.
|
||||||
|
//
|
||||||
|
// cty.dat is not wrong, it is CURRENT: Niue moved to E6, so ZK2 reverted to New
|
||||||
|
// Zealand there. ClubLog still knows ZK2 was Niue, and knowing that is the whole
|
||||||
|
// reason for enabling its country file.
|
||||||
|
func TestClublogPrefixRescuesRetiredPrefixes(t *testing.T) {
|
||||||
|
dir := filepath.Join("build", "bin", "data")
|
||||||
|
dm := dxcc.NewManager(dir)
|
||||||
|
if err := dm.LoadFromDisk(); err != nil {
|
||||||
|
t.Skipf("cty.dat not available here: %v", err)
|
||||||
|
}
|
||||||
|
cm := clublog.NewManager("", dir)
|
||||||
|
if err := cm.EnsureLoaded(); err != nil {
|
||||||
|
t.Skipf("ClubLog country file not available here: %v", err)
|
||||||
|
}
|
||||||
|
a := &App{ctx: context.Background(), dxcc: dm, clublog: cm}
|
||||||
|
|
||||||
|
when := time.Date(2005, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
want int // ADIF entity
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"ZK2KK", 188, "Niue — the reported case"},
|
||||||
|
{"ZK1XYZ", 234, "South Cook Islands, lost the same way"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
q := qso.QSO{Callsign: c.call, QSODate: when}
|
||||||
|
if !a.applyClublogException(&q, true) {
|
||||||
|
t.Errorf("%s: ClubLog changed nothing (%s)", c.call, c.why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.DXCC == nil || *q.DXCC != c.want {
|
||||||
|
got := 0
|
||||||
|
if q.DXCC != nil {
|
||||||
|
got = *q.DXCC
|
||||||
|
}
|
||||||
|
t.Errorf("%s resolved to %d (%s), want %d — %s", c.call, got, q.Country, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign ClubLog's prefix table does not know must be left to cty.dat
|
||||||
|
// rather than blanked: silence is not an answer.
|
||||||
|
q := qso.QSO{Callsign: "E6AG", QSODate: when}
|
||||||
|
if a.applyClublogException(&q, true) && q.DXCC != nil && *q.DXCC != 188 {
|
||||||
|
t.Errorf("E6AG was moved off Niue, to %d", *q.DXCC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Confirmations default of "R" means "I intend to upload this", not "it has
|
||||||
|
// gone" — reading it as sent would silently disable the auto-upload the default
|
||||||
|
// was set to arm. Only a real stamp blocks.
|
||||||
|
func TestExtrasSaysSent(t *testing.T) {
|
||||||
|
pending := []string{"", " ", "R", "r", "N", "Q", "I"}
|
||||||
|
for _, v := range pending {
|
||||||
|
if extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = true, want false (still to upload)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sent := []string{"Y", "y", "20260831"} // "Y" today, a date in older builds
|
||||||
|
for _, v := range sent {
|
||||||
|
if !extrasSaysSent(v) {
|
||||||
|
t.Errorf("extrasSaysSent(%q) = false, want true (already uploaded)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HamQTH default lands on the extras key the uploader reads, and must leave
|
||||||
|
// the QSO eligible.
|
||||||
|
func TestHamQTHDefaultStaysUploadable(t *testing.T) {
|
||||||
|
q := &qso.QSO{Callsign: "F4BPO"}
|
||||||
|
applyQSLDefaultsTo(q, defaultQSLDefaults())
|
||||||
|
if got := q.Extras[hamqthSentKey]; got != "R" {
|
||||||
|
t.Fatalf("HamQTH sent extra = %q, want %q", got, "R")
|
||||||
|
}
|
||||||
|
if extrasSaysSent(q.Extras[hamqthSentKey]) {
|
||||||
|
t.Error("a freshly logged QSO reads as already uploaded to HamQTH")
|
||||||
|
}
|
||||||
|
}
|
||||||
+625
-240
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { GripVertical, Lock } from 'lucide-react';
|
||||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -290,6 +292,116 @@ export function AppearancePanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<MatrixColorsSection />
|
<MatrixColorsSection />
|
||||||
|
<WidgetOrderSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row of widgets to the right of the entry, in the order they appear.
|
||||||
|
//
|
||||||
|
// Flexbox does the moving in the main view — this list only decides the order
|
||||||
|
// property each one gets. That is why a widget switched OFF still holds its
|
||||||
|
// place here: it comes back where the operator left it rather than at the end.
|
||||||
|
export const WIDGET_KEYS = [
|
||||||
|
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||||
|
'amp', 'tuner', 'scp', 'chasenew', 'watchlist', 'dvk', 'winkeyer', 'photo',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const WIDGET_LABELS: Record<string, string> = {
|
||||||
|
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||||
|
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||||
|
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew', watchlist: 'wo.watchlist',
|
||||||
|
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readWidgetOrder(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
// A key from an older build that no longer exists is dropped; a widget
|
||||||
|
// added since joins the end. An old preference can never hide a new one.
|
||||||
|
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||||
|
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||||
|
}
|
||||||
|
} catch { /* corrupt pref → the default order */ }
|
||||||
|
return [...WIDGET_KEYS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetOrderSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||||
|
const dragKey = useRef<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState<string | null>(null);
|
||||||
|
// Where the row would land. Drawn as a line above the target rather than by
|
||||||
|
// colouring it: the question a dragging hand asks is "between which two", and
|
||||||
|
// a highlighted row answers a different one.
|
||||||
|
const [over, setOver] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const commit = (keys: string[]) => {
|
||||||
|
setOrder(keys);
|
||||||
|
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||||
|
// The main view listens: an order is meant to be watched as it is dragged,
|
||||||
|
// not discovered after closing Preferences.
|
||||||
|
EventsEmit('widgets:order', keys);
|
||||||
|
};
|
||||||
|
const moveTo = (from: string, to: string) => {
|
||||||
|
if (from === to) return;
|
||||||
|
const next = order.filter((k) => k !== from);
|
||||||
|
const at = next.indexOf(to);
|
||||||
|
next.splice(at < 0 ? next.length : at, 0, from);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
{/* The two that cannot move, shown so the order reads as the whole row
|
||||||
|
rather than as a list that mysteriously starts at the third item. */}
|
||||||
|
{['wo.entry', 'wo.details'].map((k) => (
|
||||||
|
<div key={k}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.map((k) => (
|
||||||
|
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||||
|
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||||
|
// cannot be moved. The grip stays as the sign that it can.
|
||||||
|
<div key={k} draggable
|
||||||
|
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (over !== k) setOver(k);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!dragKey.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveTo(dragKey.current, k);
|
||||||
|
setOver(null);
|
||||||
|
}}
|
||||||
|
title={t('wo.drag')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||||
|
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||||
|
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||||
|
// The landing line, on the edge the row would take.
|
||||||
|
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||||
|
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||||
|
{t('wo.reset')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
@@ -283,6 +283,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
const range = bandRange(band);
|
const range = bandRange(band);
|
||||||
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The legend is a reference, not a running display: once its colours are
|
||||||
|
// learnt it is four lines of a short screen spent saying nothing new. Folded
|
||||||
|
// away by a toggle, and the choice is remembered.
|
||||||
|
const [legendOpen, setLegendOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('opslog.bmpLegend') !== '0'; } catch { return true; }
|
||||||
|
});
|
||||||
|
const toggleLegend = () => setLegendOpen((v) => {
|
||||||
|
const next = !v;
|
||||||
|
try { localStorage.setItem('opslog.bmpLegend', next ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
@@ -460,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
|
||||||
|
|
||||||
useEffect(() => {
|
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
|
||||||
const el = scrollerRef.current;
|
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
|
||||||
if (!el) return;
|
// app over one panel and one band map over another is a gesture nobody can
|
||||||
const onWheel = (e: WheelEvent) => {
|
// trust. The + / − buttons keep the zoom, deliberately and visibly.
|
||||||
if (!range) return;
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
changeZoom(e.deltaY > 0 ? -1 : 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
@@ -753,6 +755,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Colour legend — what each pill colour means. */}
|
{/* Colour legend — what each pill colour means. */}
|
||||||
|
{legendOpen && (
|
||||||
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
<div className="px-3 py-1 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 text-[9px] text-muted-foreground bg-muted/20 border-t border-border">
|
||||||
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
<LegendDot cls="bg-danger" label={t('bmp.legendNewDxcc')} />
|
||||||
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
<LegendDot cls="bg-warning" label={t('bmp.legendNewBand')} />
|
||||||
@@ -769,9 +772,18 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
)}
|
||||||
{t('bmp.footerHint')}
|
<div className="px-3 py-1 flex items-center gap-2 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono shrink-0">
|
||||||
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
<span className="flex-1 text-center">
|
||||||
|
{t('bmp.footerHint')}
|
||||||
|
{hidden > 0 && <span className="text-warning"> · {t('bmp.spotsHidden', { n: hidden, max: MAX_VISIBLE_SPOTS })}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={toggleLegend}
|
||||||
|
title={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
aria-label={legendOpen ? t('bmp.legendHide') : t('bmp.legendShow')}
|
||||||
|
className="shrink-0 inline-flex items-center gap-0.5 rounded px-1 py-px hover:bg-muted hover:text-foreground">
|
||||||
|
{legendOpen ? <ChevronDown className="size-3" /> : <ChevronUp className="size-3" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ interface Props {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentBand: string;
|
currentBand: string;
|
||||||
currentMode: string;
|
currentMode: string;
|
||||||
bands?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
bands?: string[];
|
||||||
|
// The operator's configured mode list, in THEIR order: the digital row
|
||||||
|
// rotates through it.
|
||||||
|
modes?: string[]; // operator's configured bands; falls back to DEFAULT_BANDS
|
||||||
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
hasCall?: boolean; // a callsign is being entered — only then highlight the "current entry" cell
|
||||||
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
// DX station coordinates, for its sunrise/sunset. Optional: many spots resolve
|
||||||
// to an entity with no position at all, and the block simply does not appear.
|
// to an entity with no position at all, and the block simply does not appear.
|
||||||
@@ -121,10 +124,31 @@ function cellTitle(t: (k: string) => string, band: string, cls: string, status:
|
|||||||
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
return `${band} ${cls}: ${desc}${mine ? ' — ' + mine : ''}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, modes, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Cell drill-down: which band+class the operator clicked, or null.
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
|
|
||||||
|
// The DIGITAL row is a rotation, not a fixed row.
|
||||||
|
//
|
||||||
|
// One row for every digital mode would be the honest layout and there is no
|
||||||
|
// height for it — the matrix sits in a fixed panel beside a dozen widgets.
|
||||||
|
// So the row keeps its place and changes what it answers: DIG (all of them),
|
||||||
|
// then each digital mode the operator actually uses, in the order their mode
|
||||||
|
// list gives, then back to DIG. The backend publishes the same cells under
|
||||||
|
// both the class name and the raw mode, so a rotation costs no round trip.
|
||||||
|
const digModes = useMemo(
|
||||||
|
() => (modes ?? [])
|
||||||
|
.map((m) => (m || '').toUpperCase().trim())
|
||||||
|
.filter((m) => m !== '' && m !== 'CW' && !PHONE_MODES.has(m)),
|
||||||
|
[modes],
|
||||||
|
);
|
||||||
|
const [digIdx, setDigIdx] = useState(0); // 0 = the DIG group itself
|
||||||
|
// A shorter mode list (the operator edited it) must not strand the rotation
|
||||||
|
// on a row that no longer exists.
|
||||||
|
const digPos = digModes.length ? digIdx % (digModes.length + 1) : 0;
|
||||||
|
const digRow = digPos === 0 ? 'DIG' : digModes[digPos - 1];
|
||||||
|
const cycleDig = () => setDigIdx((i) => (digModes.length ? (i + 1) % (digModes.length + 1) : 0));
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
// bands they actually use), falling back to the built-in default set.
|
// bands they actually use), falling back to the built-in default set.
|
||||||
const cols = useMemo(
|
const cols = useMemo(
|
||||||
@@ -310,7 +334,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
<table className="border-separate" style={{ borderSpacing: 3 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-[26px]" />
|
<th className="w-[38px] min-w-[38px] max-w-[38px]" />
|
||||||
{cols.map((b) => (
|
{cols.map((b) => (
|
||||||
<th
|
<th
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
@@ -325,13 +349,29 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CLASSES.map((cls) => {
|
{CLASSES.map((clsBase) => {
|
||||||
const classCurrent = classMatchesMode(cls, currentMode);
|
const cls = clsBase === 'DIG' ? digRow : clsBase;
|
||||||
|
// On a specific digital mode the "you are here" mark has to be that
|
||||||
|
// mode, not any digital one — otherwise every FT4 entry lights the
|
||||||
|
// FT8 row it happens to be cycled to.
|
||||||
|
const classCurrent = cls === clsBase
|
||||||
|
? classMatchesMode(cls, currentMode)
|
||||||
|
: (currentMode || '').toUpperCase() === cls;
|
||||||
return (
|
return (
|
||||||
<tr key={cls}>
|
<tr key={cls}>
|
||||||
<th
|
<th
|
||||||
|
onClick={clsBase === 'DIG' && digModes.length ? cycleDig : undefined}
|
||||||
|
title={clsBase === 'DIG' && digModes.length ? t('bsg.digCycle') : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
|
// Sized once for the LONGEST label the rotation can show,
|
||||||
|
// and pinned there: a column that grows when RTTY comes
|
||||||
|
// round shifts every band beneath it, and the eye reads
|
||||||
|
// that as the matrix moving rather than the row changing.
|
||||||
|
'font-mono font-semibold pr-1.5 text-right w-[38px] min-w-[38px] max-w-[38px] overflow-hidden',
|
||||||
|
// Beyond four characters (PSK31, MSK144) the type gives way
|
||||||
|
// instead of the column.
|
||||||
|
cls.length > 4 ? 'text-[9px]' : 'text-[11px]',
|
||||||
|
clsBase === 'DIG' && digModes.length ? 'cursor-pointer hover:text-foreground' : '',
|
||||||
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'qrz_rcvd_date', label: 'bulk.fQrzRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'clublog_sent', label: 'bulk.fClublogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'clublog_sent_date', label: 'bulk.fClublogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'clublog_rcvd', label: 'bulk.fClublogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_rcvd_date', label: 'bulk.fClublogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hrdlog_sent', label: 'bulk.fHrdlogSent', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hrdlog_sent_date', label: 'bulk.fHrdlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// HAMLOG.online: no promoted column, written into extras_json (see
|
// HAMLOG.online: no promoted column, written into extras_json (see
|
||||||
@@ -54,6 +56,8 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_sent_date', label: 'bulk.fHamlogSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
{ id: 'hamlog_rcvd', label: 'bulk.fHamlogRcvd', group: 'QSL / upload', kind: 'status' },
|
||||||
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
{ id: 'hamlog_rcvd_date', label: 'bulk.fHamlogRcvdDate', group: 'QSL / upload', kind: 'date' },
|
||||||
|
{ id: 'hamqth_sent', label: 'bulk.fHamqthSent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hamqth_sent_date', label: 'bulk.fHamqthSentDate', group: 'QSL / upload', kind: 'date' },
|
||||||
// My station / operator
|
// My station / operator
|
||||||
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Radar, Loader2, X } from 'lucide-react';
|
import { Radar, Loader2, X } from 'lucide-react';
|
||||||
import { formatDistance } from '@/lib/units';
|
import { formatDistance } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
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;
|
||||||
@@ -60,6 +61,10 @@ const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// categoryOf is the single thing a row says about a station.
|
// categoryOf is the single thing a row says about a station.
|
||||||
|
//
|
||||||
|
// A category the operator does not chase is not a category here either: with
|
||||||
|
// prefixes and squares switched off this panel showed rows whose only reason
|
||||||
|
// for being listed had been withdrawn everywhere else.
|
||||||
function categoryOf(s: ChaseNewSpot): Category | null {
|
function categoryOf(s: ChaseNewSpot): Category | null {
|
||||||
switch (s.status) {
|
switch (s.status) {
|
||||||
case 'new': return 'dxcc';
|
case 'new': return 'dxcc';
|
||||||
@@ -68,22 +73,32 @@ function categoryOf(s: ChaseNewSpot): Category | null {
|
|||||||
case 'new-mode': return 'mode';
|
case 'new-mode': return 'mode';
|
||||||
case 'new-slot': return 'slot';
|
case 'new-slot': return 'slot';
|
||||||
}
|
}
|
||||||
if (s.new_pfx) return 'pfx';
|
if (s.new_pfx && chaseAllows('pfx')) return 'pfx';
|
||||||
if (s.new_grid) return 'grid';
|
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
||||||
return null;
|
return 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.map((c) => c.key));
|
return new Set(allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||||
@@ -91,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
|
||||||
@@ -101,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();
|
||||||
@@ -132,7 +153,7 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
|||||||
|
|
||||||
{/* Filters, in the same order and colours as the badges they hide. */}
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-1">
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
||||||
{CATEGORIES.map((c) => (
|
{CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.key}
|
key={c.key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -149,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
|
||||||
@@ -213,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
pfx?: string;
|
pfx?: string;
|
||||||
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
: s?.status === 'new-slot' ? t('clg2.newSlot')
|
||||||
: s?.status === 'new-call' ? t('clg2.newCall')
|
: s?.status === 'new-call' ? t('clg2.newCall')
|
||||||
: t('clg2.wkdCall');
|
: t('clg2.wkdCall');
|
||||||
parts.push({ text: label, color: main });
|
// Dimmed when the need is only a missing confirmation — the grid's
|
||||||
|
// own convention, now spoken by every category.
|
||||||
|
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
|
||||||
}
|
}
|
||||||
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
// Colours from lib/spotMarkers — shared with the band map so a marker is
|
||||||
// never one colour here and another there.
|
// never one colour here and another there.
|
||||||
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
|
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
|
||||||
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
|
||||||
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
|
||||||
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
|
||||||
// held back, so it reads as "less" of the same thing rather than a
|
// held back, so it reads as "less" of the same thing rather than a
|
||||||
// different fact — and the label says which.
|
// different fact — and the label says which.
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { RefreshCw, Star, ExternalLink } from 'lucide-react';
|
||||||
|
import { GetDXpeditions, GetDXWorldNews, RefreshDXpeditions, WatchlistEntries, WatchlistAdd } from '../../wailsjs/go/main/App';
|
||||||
|
import { BrowserOpenURL, EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// DXpeditions — the two feeds the DX world announces itself on, side by side.
|
||||||
|
//
|
||||||
|
// Left: NG3K's ADXO, the structured announcements, each judged against THIS log
|
||||||
|
// so the list reads as "what I still need" rather than "what is on". Right:
|
||||||
|
// DX-World's headlines, whose callsigns are mined out of the title so they can
|
||||||
|
// be watched with the same one click.
|
||||||
|
|
||||||
|
type DXped = {
|
||||||
|
dxcc: string; callsign: string; calls?: string[];
|
||||||
|
start_date: string; end_date: string;
|
||||||
|
bands?: string[]; modes?: string[];
|
||||||
|
qsl: string; operators: string; source: string; link: string;
|
||||||
|
status: string; // active | upcoming
|
||||||
|
status_chase: string; // new | new-band-mode | new-band | new-mode | new-slot | worked | ''
|
||||||
|
unconfirmed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type News = {
|
||||||
|
title: string; link: string; pub_date: string; excerpt: string;
|
||||||
|
creator: string; image_url: string; tag: string; calls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge per expedition, the strongest verdict winning — the same palette
|
||||||
|
// and the same words the cluster uses, so the two views teach one vocabulary.
|
||||||
|
const CHASE_BADGE: Record<string, { label: string; colour: string }> = {
|
||||||
|
'new': { label: 'clg2.newDxcc', colour: 'var(--danger)' },
|
||||||
|
'new-band-mode': { label: 'clg2.newBandMode', colour: 'var(--danger)' },
|
||||||
|
'new-band': { label: 'clg2.newBand', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'clg2.newMode', colour: 'var(--caution)' },
|
||||||
|
'new-slot': { label: 'clg2.newSlot', colour: '#5AC8FA' },
|
||||||
|
'worked': { label: 'wl.worked', colour: 'var(--info)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DXpeditionsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [peds, setPeds] = useState<DXped[]>([]);
|
||||||
|
const [news, setNews] = useState<News[]>([]);
|
||||||
|
const [watched, setWatched] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [neededOnly, setNeededOnly] = useState(() => localStorage.getItem('opslog.dxpedNeeded') === '1');
|
||||||
|
|
||||||
|
const loadWatchlist = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const e: any[] = await WatchlistEntries();
|
||||||
|
setWatched(new Set((e ?? []).map((x) => String(x.callsign ?? '').toUpperCase())));
|
||||||
|
} catch { /* the list simply shows every call as unwatched */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErr('');
|
||||||
|
const [p, n] = await Promise.allSettled([GetDXpeditions(), GetDXWorldNews()]);
|
||||||
|
if (p.status === 'fulfilled') setPeds((p.value as any) ?? []);
|
||||||
|
else setErr(String((p.reason as any)?.message ?? p.reason));
|
||||||
|
if (n.status === 'fulfilled') setNews((n.value as any) ?? []);
|
||||||
|
setBusy(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); void loadWatchlist(); }, [load, loadWatchlist]);
|
||||||
|
useEffect(() => EventsOn('watchlist:changed', () => { void loadWatchlist(); }), [loadWatchlist]);
|
||||||
|
|
||||||
|
const refresh = async () => { await RefreshDXpeditions(); await load(); };
|
||||||
|
|
||||||
|
const addAll = async (calls: string[]) => {
|
||||||
|
for (const c of calls) {
|
||||||
|
if (!watched.has(c.toUpperCase())) {
|
||||||
|
try { await WatchlistAdd(c, false); } catch { /* reported by the notice */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadWatchlist();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (neededOnly ? peds.filter((p) => p.status_chase && p.status_chase !== 'worked') : peds),
|
||||||
|
[peds, neededOnly]);
|
||||||
|
|
||||||
|
// A row's calls: the mined ones, else whatever the announcement called it.
|
||||||
|
const callsOf = (p: DXped) => (p.calls?.length ? p.calls : p.callsign ? [p.callsign] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 gap-3">
|
||||||
|
{/* ── Announcements (ADXO) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[3] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.announced')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'NG3K ADXO' })}</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-[11px] cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
<input type="checkbox" checked={neededOnly}
|
||||||
|
onChange={(e) => { setNeededOnly(e.target.checked); localStorage.setItem('opslog.dxpedNeeded', e.target.checked ? '1' : '0'); }} />
|
||||||
|
{t('dxp.neededOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={busy}>
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('dxp.refresh')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{err && <div className="px-3 py-2 text-xs text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{shown.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.none')}</p>
|
||||||
|
)}
|
||||||
|
{shown.map((p, i) => {
|
||||||
|
const calls = callsOf(p);
|
||||||
|
const badge = CHASE_BADGE[p.status_chase];
|
||||||
|
const allWatched = calls.length > 0 && calls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${p.callsign}-${p.start_date}-${i}`}
|
||||||
|
className={cn('rounded-md border p-2 text-xs',
|
||||||
|
p.status === 'active' ? 'border-success/40 bg-success/5' : 'border-border bg-muted/20')}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-sm text-info">{p.callsign || '—'}</span>
|
||||||
|
<span className="font-medium">{p.dxcc}</span>
|
||||||
|
{p.status === 'active' && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase bg-success-muted text-success-muted-foreground">
|
||||||
|
{t('dxp.onAir')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1 py-px rounded text-[10px] font-bold uppercase tracking-wide border"
|
||||||
|
title={p.unconfirmed ? t('dec.unconfTip') : undefined}
|
||||||
|
style={p.unconfirmed
|
||||||
|
? { color: badge.colour, borderColor: badge.colour, borderStyle: 'dashed', opacity: 0.6 }
|
||||||
|
: { color: badge.colour, borderColor: badge.colour }}>
|
||||||
|
{t(badge.label)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{p.start_date} → {p.end_date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-2 flex-wrap text-[11px] text-muted-foreground">
|
||||||
|
{!!p.bands?.length && <span>{p.bands.join(' · ')}</span>}
|
||||||
|
{!!p.modes?.length && <span className="text-foreground/70">{p.modes.join(' ')}</span>}
|
||||||
|
{p.qsl && <span>QSL: {p.qsl}</span>}
|
||||||
|
{p.source && <span>· {p.source}</span>}
|
||||||
|
</div>
|
||||||
|
{p.operators && <p className="mt-0.5 text-[11px] text-muted-foreground truncate">{p.operators}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={allWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={calls.length === 0 || allWatched}
|
||||||
|
onClick={() => addAll(calls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', allWatched && 'fill-current text-warning')} />
|
||||||
|
{allWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{p.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(p.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── News (DX-World) ── */}
|
||||||
|
<section className="flex flex-col min-h-0 flex-[2] rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-sm font-semibold">{t('dxp.news')}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{t('dxp.source', { name: 'DX-World' })}</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{news.length === 0 && !busy && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-6">{t('dxp.noNews')}</p>
|
||||||
|
)}
|
||||||
|
{news.map((n, i) => {
|
||||||
|
const newsCalls = n.calls ?? [];
|
||||||
|
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
|
||||||
|
return (
|
||||||
|
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{n.image_url && (
|
||||||
|
<img src={n.image_url} alt="" className="size-12 rounded object-cover shrink-0"
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.tag && (
|
||||||
|
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
|
||||||
|
className="text-xs font-medium text-left hover:underline">{n.title}</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground line-clamp-3">{n.excerpt}</p>
|
||||||
|
{/* The callsigns are shown, not clicked: watching is the same
|
||||||
|
one button as an announcement, so the gesture is learned
|
||||||
|
once for the whole tab. */}
|
||||||
|
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{n.pub_date && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(n.pub_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(n.calls ?? []).map((c) => (
|
||||||
|
<span key={c} className={cn('font-mono text-[10px]',
|
||||||
|
watched.has(c.toUpperCase()) ? 'text-warning' : 'text-info')}>
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button variant={newsAllWatched ? 'ghost' : 'outline'} size="sm" className="h-6 text-[11px]"
|
||||||
|
disabled={newsCalls.length === 0 || newsAllWatched}
|
||||||
|
onClick={() => addAll(newsCalls)}
|
||||||
|
title={t('dxp.watchTip')}>
|
||||||
|
<Star className={cn('size-3', newsAllWatched && 'fill-current text-warning')} />
|
||||||
|
{newsAllWatched ? t('dxp.watched') : t('dxp.watch')}
|
||||||
|
</Button>
|
||||||
|
{n.link && (
|
||||||
|
<button type="button" onClick={() => BrowserOpenURL(n.link)}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ExternalLink className="size-3" /> {t('dxp.open')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,11 +13,16 @@
|
|||||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||||
// both panels rather than being judged twice by two rules.
|
// both panels rather than being judged twice by two rules.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||||
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 { 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';
|
||||||
|
|
||||||
export type Decode = {
|
export type Decode = {
|
||||||
call: string;
|
call: string;
|
||||||
@@ -72,8 +77,14 @@ type StatusEntry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
new_state?: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
|
unconf_state?: boolean;
|
||||||
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
|
||||||
grid_state?: string;
|
grid_state?: string;
|
||||||
|
state?: string;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
lotw?: boolean;
|
lotw?: boolean;
|
||||||
};
|
};
|
||||||
@@ -88,8 +99,17 @@ interface Props {
|
|||||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||||
txStates?: Record<string, TxMsg>;
|
txStates?: Record<string, TxMsg>;
|
||||||
spotStatus: Record<string, StatusEntry>;
|
spotStatus: Record<string, StatusEntry>;
|
||||||
|
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||||
|
// the decoder announces — see the drift warning.
|
||||||
|
rigBand?: string;
|
||||||
onCall: (d: Decode) => void;
|
onCall: (d: Decode) => void;
|
||||||
|
// A single click: take the station without transmitting — fill the entry, and
|
||||||
|
// point the panels at it. Absent, a click falls back to onCall.
|
||||||
|
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.
|
||||||
@@ -105,6 +125,15 @@ interface Props {
|
|||||||
// machine off is not something to go hunting through a settings tree for.
|
// machine off is not something to go hunting through a settings tree for.
|
||||||
autoCallOn?: boolean;
|
autoCallOn?: boolean;
|
||||||
onToggleAutoCall?: () => void;
|
onToggleAutoCall?: () => void;
|
||||||
|
// The engine's own account of what it is doing, straight from the backend.
|
||||||
|
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
|
||||||
|
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||||
|
autoCallOnly?: string;
|
||||||
|
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
|
||||||
@@ -112,7 +141,10 @@ interface Props {
|
|||||||
//
|
//
|
||||||
// All off means no filtering at all: this is a decode LOG first, and a panel
|
// All off means no filtering at all: this is a decode LOG first, and a panel
|
||||||
// that starts by hiding most of the band would be lying about what is on it.
|
// that starts by hiding most of the band would be lying about what is on it.
|
||||||
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
|
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
|
||||||
|
|
||||||
|
// The seven, in the order an operator reads them.
|
||||||
|
const CONTINENTS = ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'];
|
||||||
|
|
||||||
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
||||||
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
|
||||||
@@ -123,6 +155,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
|
|||||||
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
|
||||||
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
|
||||||
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
|
||||||
|
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// catsOf lists everything a decode is new for. A station can be several at once
|
// catsOf lists everything a decode is new for. A station can be several at once
|
||||||
@@ -143,9 +176,25 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
|||||||
if (e.new_grid) out.add('grid');
|
if (e.new_grid) out.add('grid');
|
||||||
if (e.new_pfx) out.add('pfx');
|
if (e.new_pfx) out.add('pfx');
|
||||||
if (e.new_county) out.add('cty');
|
if (e.new_county) out.add('cty');
|
||||||
|
if (e.new_state) out.add('state');
|
||||||
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';
|
||||||
@@ -227,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' | '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 },
|
||||||
@@ -241,9 +295,31 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
|||||||
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
|
||||||
// an operator chasing squares could see the verdict but never the square.
|
// an operator chasing squares could see the verdict but never the square.
|
||||||
{ 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
|
||||||
|
// 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: '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 },
|
||||||
];
|
];
|
||||||
|
const US_STATES: Record<string, string> = {
|
||||||
|
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
||||||
|
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
||||||
|
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
||||||
|
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
||||||
|
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
|
||||||
|
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
|
||||||
|
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
|
||||||
|
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
|
||||||
|
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
|
||||||
|
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
|
||||||
|
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
|
||||||
|
};
|
||||||
|
|
||||||
const COL_MAX = 600;
|
const COL_MAX = 600;
|
||||||
const COLW_KEY = 'opslog.decodeColWidths';
|
const COLW_KEY = 'opslog.decodeColWidths';
|
||||||
|
|
||||||
@@ -308,12 +384,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
|
|||||||
//
|
//
|
||||||
// Colours match the cluster list and the band map — the same fact must not be
|
// Colours match the cluster list and the band map — the same fact must not be
|
||||||
// amber in one panel and green in the next.
|
// amber in one panel and green in the next.
|
||||||
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
|
||||||
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
|
// colour is the category's own hue, for the UNCONFIRMED rendering: the
|
||||||
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
|
// filled cls puts light text on a filled chip, and dimming that to a
|
||||||
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
|
// transparent background left light-on-nothing — an invisible badge.
|
||||||
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
|
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
|
||||||
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
|
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
|
||||||
|
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
|
||||||
|
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
|
||||||
|
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// entityBadgesFor turns a status into the badges that describe it.
|
// entityBadgesFor turns a status into the badges that describe it.
|
||||||
@@ -324,7 +403,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
|
|||||||
// passed the BAND and MODE filters and then showed no reason for being there,
|
// passed the BAND and MODE filters and then showed no reason for being there,
|
||||||
// which is precisely what was reported. catsOf has always split the status into
|
// which is precisely what was reported. catsOf has always split the status into
|
||||||
// its two categories; this is the same split, on the screen.
|
// its two categories; this is the same split, on the screen.
|
||||||
function entityBadgesFor(status: string): { label: string; cls: string }[] {
|
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
|
||||||
if (status === 'new-band-mode') {
|
if (status === 'new-band-mode') {
|
||||||
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
|
||||||
}
|
}
|
||||||
@@ -343,6 +422,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
|
|||||||
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
|
||||||
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
|
||||||
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
|
||||||
|
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
|
||||||
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
|
||||||
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
|
||||||
];
|
];
|
||||||
@@ -376,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>;
|
||||||
@@ -404,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
|
||||||
@@ -420,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">
|
||||||
@@ -498,21 +600,29 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
// the same way it was grouped.
|
// the same way it was grouped.
|
||||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||||
tx: g.tx,
|
tx: g.tx,
|
||||||
// Strongest first inside a period: the eye should land on what is
|
// ARRIVAL order inside a period, per the operator: it mirrors the
|
||||||
// workable, and time within a slot means nothing — they were all
|
// decoder's own window line for line, which makes the two screens
|
||||||
// transmitting simultaneously.
|
// comparable at a glance — the strongest-first sort scrambled that
|
||||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
// correspondence, and SNR is right there in its column anyway.
|
||||||
|
decodes: g.decodes,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: 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/
|
||||||
// like every other portable preference.
|
// like every other portable preference.
|
||||||
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
||||||
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
|
// The chase list as TYPED. Re-seeded whenever the stored value changes —
|
||||||
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
|
// from Preferences, or from another window — but never while the box has the
|
||||||
|
// focus, or a status arriving mid-word would rewrite what is being typed.
|
||||||
|
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
|
||||||
|
useEffect(() => {
|
||||||
|
const el = document.activeElement as HTMLElement | null;
|
||||||
|
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
||||||
|
setOnlyText(autoCallOnly ?? '');
|
||||||
|
}, [autoCallOnly, t]);
|
||||||
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)));
|
||||||
@@ -563,6 +673,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
|
|
||||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||||
// best; between overs the transmit state still does.
|
// best; between overs the transmit state still does.
|
||||||
|
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||||
|
// frequency it knew, and every decode after that carries a stale band. Nothing
|
||||||
|
// downstream can tell: the entity verdicts, the band filter and the FT map all
|
||||||
|
// believe what the decoder said, and an operator ends up reading NEW BAND for a
|
||||||
|
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
|
||||||
|
// Korea showed as a new band because on 80 m it would have been.)
|
||||||
|
//
|
||||||
|
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||||
|
// decoding a second receiver on another band, and a warning costs that setup
|
||||||
|
// nothing but a line it can read past.
|
||||||
|
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||||
|
const bandDrift = !!rigBand && !!decoderBand
|
||||||
|
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||||
|
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||||
|
|
||||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||||
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||||
|
|
||||||
@@ -574,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// 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;
|
||||||
@@ -599,27 +726,31 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// dropdowns were pure furniture for most operators; they appear the day a
|
// dropdowns were pure furniture for most operators; they appear the day a
|
||||||
// second instance puts a second band on the link, which is the only day they
|
// second instance puts a second band on the link, which is the only day they
|
||||||
// mean anything.
|
// mean anything.
|
||||||
const { bands, modes, conts, instances } = useMemo(() => {
|
const { bands, modes, instances } = useMemo(() => {
|
||||||
const b = new Set<string>(), m = new Set<string>(), c = new Set<string>(), i = new Set<string>();
|
const b = new Set<string>(), m = new Set<string>(), i = new Set<string>();
|
||||||
for (const d of decodes) {
|
for (const d of decodes) {
|
||||||
if (d.band) b.add(d.band);
|
if (d.band) b.add(d.band);
|
||||||
if (d.mode) m.add(d.mode);
|
if (d.mode) m.add(d.mode);
|
||||||
if (d.instance) i.add(d.instance);
|
if (d.instance) i.add(d.instance);
|
||||||
const ct = statusOf(d)?.continent;
|
|
||||||
if (ct) c.add(ct);
|
|
||||||
}
|
}
|
||||||
return { bands: [...b].sort(), modes: [...m].sort(), conts: [...c].sort(), instances: [...i].sort() };
|
return { bands: [...b].sort(), modes: [...m].sort(), instances: [...i].sort() };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [decodes, spotStatus]);
|
}, [decodes, spotStatus]);
|
||||||
|
|
||||||
// The chips are the continents on the feed — a selector with nothing to choose
|
// The receiver column is dead weight with one decoder — which is nearly
|
||||||
// is furniture — PLUS anything currently selected. Without that second half a
|
// everybody — so it is not there at all until a second one starts feeding.
|
||||||
// filter can strand itself: pick AF, the last African station stops decoding,
|
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
|
||||||
// and the list empties with no chip left to switch it back off.
|
[instances.length, splitByInstance]);
|
||||||
const contChips = useMemo(
|
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
|
||||||
() => [...new Set([...conts, ...contList])].sort(),
|
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
|
||||||
[conts, contList],
|
// All seven, always, in their usual order.
|
||||||
);
|
//
|
||||||
|
// The chips used to be built from the continents ON the feed, which read as a
|
||||||
|
// list that reshuffled itself every period: a chip appeared when the first
|
||||||
|
// Asian station decoded and moved everything sideways under the pointer. A
|
||||||
|
// fixed row can be aimed at — you learn where OC is and it stays there — and
|
||||||
|
// it also says what the filter can do before anything has been heard.
|
||||||
|
const contChips = CONTINENTS;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toUpperCase();
|
const q = search.trim().toUpperCase();
|
||||||
@@ -645,6 +776,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
});
|
});
|
||||||
// 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.
|
||||||
@@ -660,7 +807,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
}
|
}
|
||||||
return instances.map((inst) => ({
|
return instances.map((inst) => ({
|
||||||
key: inst,
|
key: inst,
|
||||||
label: inst,
|
// What the program is called, not the id it announces — see decoderName.
|
||||||
|
label: decoderName(inst),
|
||||||
tx: txStates?.[inst],
|
tx: txStates?.[inst],
|
||||||
periods: buildPeriods(
|
periods: buildPeriods(
|
||||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||||
@@ -700,7 +848,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* 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 && (
|
||||||
|
<span
|
||||||
|
title={t('dec.bandDriftTip')}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||||
|
<AlertTriangle className="size-3.5" />
|
||||||
|
{t('dec.bandDrift', {
|
||||||
|
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
|
||||||
|
dec: decoderBand.toUpperCase(),
|
||||||
|
rig: (rigBand ?? '').toUpperCase(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||||
|
|
||||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||||
@@ -713,7 +877,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* Per-category badges, in the colours of the flags they select — the
|
{/* Per-category badges, in the colours of the flags they select — the
|
||||||
same vocabulary as the Chase New panel. */}
|
same vocabulary as the Chase New panel. */}
|
||||||
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.catsHint')}>
|
||||||
{NEW_CATS.map((c) => {
|
{NEW_CATS.filter((c) => chaseAllows(c.key)).map((c) => {
|
||||||
const on = cats.has(c.key);
|
const on = cats.has(c.key);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -827,6 +991,62 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
filter. Halt goes LAST — it is the one that must be findable without
|
filter. Halt goes LAST — it is the one that must be findable without
|
||||||
reading, and the end of the row is the one position that never moves
|
reading, and the end of the row is the one position that never moves
|
||||||
as filters come and go. */}
|
as filters come and go. */}
|
||||||
|
{/* Auto-call. Deliberately next to Halt: the two belong together, and
|
||||||
|
what it is doing right now — which station, how many calls of how
|
||||||
|
many — is on the button itself, because a thing that keys the
|
||||||
|
transmitter must never be a switch with no readout. */}
|
||||||
|
{onToggleAutoCall && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleAutoCall}
|
||||||
|
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
|
||||||
|
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
|
||||||
|
!autoCallOn
|
||||||
|
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||||
|
: autoCall?.stopped
|
||||||
|
? 'border-warning bg-warning text-warning-foreground'
|
||||||
|
: 'border-success bg-success text-success-foreground')}
|
||||||
|
>
|
||||||
|
<Bot className="size-3.5" />
|
||||||
|
{t('dec.autoCall')}
|
||||||
|
{autoCallOn && autoCall?.target && (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
||||||
|
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||||
|
the stored value is upper-cased and trimmed, and binding the box to
|
||||||
|
that makes the space bar look dead — in a field whose whole purpose
|
||||||
|
is a list separated by spaces. */}
|
||||||
|
{onSetAutoCallOnly && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={onlyText}
|
||||||
|
onChange={(e) => setOnlyText(e.target.value)}
|
||||||
|
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
|
||||||
|
}}
|
||||||
|
placeholder={t('dec.chasePh')}
|
||||||
|
title={t('dec.chaseTip')}
|
||||||
|
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
|
||||||
|
(autoCallOnly ?? '').trim()
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-border text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{onHalt && (
|
{onHalt && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -893,7 +1113,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||||
{txState.instance && instances.length > 1 && (
|
{txState.instance && instances.length > 1 && (
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -952,17 +1172,17 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<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)}
|
||||||
@@ -1021,7 +1241,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
const e = statusOf(d);
|
const e = statusOf(d);
|
||||||
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
const st = e?.status && e.status !== 'worked' ? e.status : '';
|
||||||
const entities = st ? entityBadgesFor(st) : [];
|
const entities = st ? entityBadgesFor(st) : [];
|
||||||
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
|
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key] && chaseAllows(b.key as string));
|
||||||
const mine = !!me && d.call === me;
|
const mine = !!me && d.call === me;
|
||||||
const hot = entities.length > 0 || extras.length > 0;
|
const hot = entities.length > 0 || extras.length > 0;
|
||||||
// Someone answering us outranks everything else on the screen.
|
// Someone answering us outranks everything else on the screen.
|
||||||
@@ -1033,11 +1253,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<button
|
<button
|
||||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCall(d)}
|
// ONE click selects, TWO transmit — the cluster's rule, and
|
||||||
|
// the only safe one here: a single click used to hand the
|
||||||
|
// decode straight to WSJT-X as a Reply, so brushing a row
|
||||||
|
// while reading the band started calling a station.
|
||||||
|
onClick={() => (onSelect ?? onCall)(d)}
|
||||||
|
onDoubleClick={() => onCall(d)}
|
||||||
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')}
|
||||||
@@ -1050,6 +1278,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{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>
|
||||||
@@ -1083,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{/* 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
|
||||||
@@ -1096,6 +1333,23 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
<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')}>
|
||||||
|
{e?.state && (
|
||||||
|
<>
|
||||||
|
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
|
||||||
<span className="truncate">{e?.country ?? ''}</span>
|
<span className="truncate">{e?.country ?? ''}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1112,8 +1366,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
{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} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
|
<span key={b.label}
|
||||||
|
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
|
||||||
|
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
|
||||||
|
e?.unconf_status ? 'border bg-transparent' : b.cls)}
|
||||||
|
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
|
||||||
{t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -1123,16 +1390,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
|||||||
// Same hue, held back — the badge reads as "less" of the
|
// Same hue, held back — the badge reads as "less" of the
|
||||||
// same thing, and its label says which. One badge for both
|
// same thing, and its label says which. One badge for both
|
||||||
// is what made a station worked an hour ago read as new.
|
// is what made a station worked an hour ago read as new.
|
||||||
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
|
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|
||||||
|
|| (b.key === 'new_state' && !!e?.unconf_state)
|
||||||
|
|| (b.key === 'new_county' && !!e?.unconf_cty)
|
||||||
|
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
|
||||||
const c = markerColour(b.marker);
|
const c = markerColour(b.marker);
|
||||||
return (
|
return (
|
||||||
<span key={b.key as string}
|
<span key={b.key as string}
|
||||||
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
|
title={unconf ? t('dec.unconfTip') : undefined}
|
||||||
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
|
||||||
style={unconf
|
style={unconf
|
||||||
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
|
||||||
: { borderColor: c, color: c }}>
|
: { borderColor: c, color: c }}>
|
||||||
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
|
{t(b.label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface Props {
|
|||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
bands?: string[]; // configured bands for the worked-before matrix columns
|
bands?: string[]; // configured bands for the worked-before matrix columns
|
||||||
|
modes?: string[]; // configured modes, in order — the matrix cycles its digital row through them
|
||||||
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
// The station's satellites, for the SAT_NAME dropdown. Passed in rather than
|
||||||
// read here: the list lives in Preferences, and App already reloads it when
|
// read here: the list lives in Preferences, and App already reloads it when
|
||||||
// Preferences close — a panel reading it once at mount would need a restart.
|
// Preferences close — a panel reading it once at mount would need a restart.
|
||||||
@@ -155,7 +156,7 @@ function Field({ label, span = 1, className, children }: { label: string; span?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth, name, country, comment, note, details, onChange, wb, wbBusy, band, mode, bands, modes, satellites = [], slotCall, slotBand, slotMode, slotWb, slotWbBusy, tab, onTab, keyerActive, onEditQso }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||||
@@ -294,6 +295,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
|||||||
currentBand={slotCall ? (slotBand ?? '') : band}
|
currentBand={slotCall ? (slotBand ?? '') : band}
|
||||||
currentMode={slotCall ? (slotMode ?? '') : mode}
|
currentMode={slotCall ? (slotMode ?? '') : mode}
|
||||||
bands={bands}
|
bands={bands}
|
||||||
|
modes={modes}
|
||||||
hasCall={slotCall ? true : callsign.trim() !== ''}
|
hasCall={slotCall ? true : callsign.trim() !== ''}
|
||||||
forCall={slotCall}
|
forCall={slotCall}
|
||||||
onEditQso={onEditQso}
|
onEditQso={onEditQso}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Props = {
|
|||||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type KenwoodState = {
|
|||||||
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||||
s_meter: number; s_meter_raw: number;
|
s_meter: number; s_meter_raw: number;
|
||||||
power_meter: number; swr: number; swr_raw: number;
|
power_meter: number; power_w?: number; swr: number; swr_raw: number;
|
||||||
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
||||||
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||||
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||||
@@ -222,7 +222,8 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||||
}}
|
}}
|
||||||
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||||
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
|
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
|
||||||
|
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
|
||||||
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||||
a match that looks ideal on an antenna nobody has measured is the one
|
a match that looks ideal on an antenna nobody has measured is the one
|
||||||
reading that can cost a radio. */}
|
reading that can cost a radio. */}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||||
|
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
// FT Map — the live decode feed as geography: every station decoded in the
|
||||||
|
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||||
|
// band the way PSK Reporter taught everyone to read it. Wholly display: the
|
||||||
|
// decode list is the same one the FT decodes tab shows, and a station with no
|
||||||
|
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
|
||||||
|
//
|
||||||
|
// Performance is a design constraint, not an afterthought: the panel only
|
||||||
|
// exists while its tab is active (the parent unmounts it otherwise), the map
|
||||||
|
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
|
||||||
|
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
|
||||||
|
// not per frame.
|
||||||
|
|
||||||
|
export type FTMapDecode = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
band?: string;
|
||||||
|
snr: number;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The band palette every PSK Reporter user already knows, near enough.
|
||||||
|
const BAND_COLOURS: Record<string, string> = {
|
||||||
|
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
|
||||||
|
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
|
||||||
|
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
|
||||||
|
'2m': '#ff1493', '70cm': '#999900',
|
||||||
|
};
|
||||||
|
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
|
||||||
|
|
||||||
|
const MAX_ARCS = 300;
|
||||||
|
const MAX_AGE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const baseRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||||
|
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
|
||||||
|
|
||||||
|
// The map itself, once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current || mapRef.current) return;
|
||||||
|
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
|
||||||
|
// and the space beyond the edge is the theme's own surface (style.css).
|
||||||
|
const m = L.map(divRef.current, {
|
||||||
|
zoomControl: true, attributionControl: true,
|
||||||
|
// No maxBounds: with the world smaller than the window the clamp
|
||||||
|
// dragged every zoom into a corner. noWrap tiles alone keep one world.
|
||||||
|
worldCopyJump: false, preferCanvas: true,
|
||||||
|
center: [25, 0], zoom: 2, minZoom: 2,
|
||||||
|
});
|
||||||
|
mapRef.current = m;
|
||||||
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
|
// Leaflet measures its container ONCE, when the map is created, and then
|
||||||
|
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||||
|
// tab is selected — before the flex layout has settled — and the window can
|
||||||
|
// be resized under it, so the stale measurement showed as a strip of dead
|
||||||
|
// space along the bottom where tiles were never asked for. The observer
|
||||||
|
// hands it the real size whenever the box changes.
|
||||||
|
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
|
||||||
|
ro.observe(divRef.current);
|
||||||
|
// Once more after the first paint: the first observation can arrive while
|
||||||
|
// the panel is still zero-height, and no further resize follows a layout
|
||||||
|
// that settles by itself.
|
||||||
|
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settle);
|
||||||
|
ro.disconnect();
|
||||||
|
m.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
layerRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Basemap follows the picker.
|
||||||
|
useEffect(() => {
|
||||||
|
const m = mapRef.current;
|
||||||
|
if (!m) return;
|
||||||
|
baseRef.current?.remove(); labelsRef.current?.remove();
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
const opts: L.TileLayerOptions = {
|
||||||
|
maxNativeZoom: bm.maxNativeZoom,
|
||||||
|
noWrap: true,
|
||||||
|
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
|
||||||
|
};
|
||||||
|
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
|
||||||
|
localStorage.setItem('opslog.ftmapBase', basemap);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||||
|
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
const from = gridToLatLon(myGrid);
|
||||||
|
if (!from) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const placed = decodes
|
||||||
|
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
|
||||||
|
.slice(-MAX_ARCS);
|
||||||
|
// One line per CALL (its freshest sighting): the same CQer decoded thirty
|
||||||
|
// times in ten minutes is one path on the air, not thirty strokes of it.
|
||||||
|
const byCall = new Map<string, FTMapDecode>();
|
||||||
|
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
|
||||||
|
L.circleMarker([from.lat, from.lon], {
|
||||||
|
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
|
||||||
|
}).addTo(layer);
|
||||||
|
for (const d of byCall.values()) {
|
||||||
|
const to = gridToLatLon(d.grid!);
|
||||||
|
if (!to) continue;
|
||||||
|
const age = now - Date.parse(d.at);
|
||||||
|
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||||
|
const colour = bandColour(d.band);
|
||||||
|
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||||
|
// past ±180 has to leave one edge and come back at the other. Without it
|
||||||
|
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||||
|
// of the map, its far end sitting alone on the opposite coast.
|
||||||
|
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.circleMarker([to.lat, to.lon], {
|
||||||
|
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
|
||||||
|
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}, [decodes, myGrid]);
|
||||||
|
|
||||||
|
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||||
|
return (
|
||||||
|
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||||
|
// beat the app menus and the Settings dialog. A stacking context of our own
|
||||||
|
// keeps all of it inside this panel.
|
||||||
|
<div className="relative isolate z-0 h-full w-full min-h-0">
|
||||||
|
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
|
||||||
|
{/* Basemap picker, MainMap's own vocabulary. */}
|
||||||
|
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button key={k} type="button" onClick={() => setBasemap(k)}
|
||||||
|
className={cn('px-2 py-0.5 rounded text-[11px]',
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Band legend — only the bands actually on screen. */}
|
||||||
|
{bands.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
|
||||||
|
{bands.map((b) => (
|
||||||
|
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
|
||||||
|
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
|
||||||
|
{b.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!gridToLatLon(myGrid) && (
|
||||||
|
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{t('ftmap.noGrid')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
{ value: 'qrzcom_qso_download_date', label: 'fltb.fQrzRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'fltb.fClublogSent', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
{ value: 'clublog_qso_upload_date', label: 'fltb.fClublogSentDate', type: 'adifdate' },
|
||||||
|
{ value: 'clublog_qso_download_status', label: 'fltb.fClublogRcvd', type: 'text' },
|
||||||
|
{ value: 'clublog_qso_download_date', label: 'fltb.fClublogRcvdDate', type: 'adifdate' },
|
||||||
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
{ value: 'hrdlog_qso_upload_status', label: 'fltb.fHrdlogSent', type: 'text' },
|
||||||
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
{ value: 'hrdlog_qso_upload_date', label: 'fltb.fHrdlogSentDate', type: 'adifdate' },
|
||||||
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
// HAMLOG.online: no promoted column, filtered through extras_json (see
|
||||||
@@ -94,6 +96,8 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
{ value: 'hamlog_sent_date', label: 'fltb.fHamlogSentDate', type: 'adifdate' },
|
||||||
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
{ value: 'hamlog_rcvd', label: 'fltb.fHamlogRcvd', type: 'text' },
|
||||||
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
{ value: 'hamlog_rcvd_date', label: 'fltb.fHamlogRcvdDate', type: 'adifdate' },
|
||||||
|
{ value: 'hamqth_sent', label: 'fltb.fHamqthSent', type: 'text' },
|
||||||
|
{ value: 'hamqth_sent_date', label: 'fltb.fHamqthSentDate', type: 'adifdate' },
|
||||||
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
{ value: 'contest_id', label: 'fltb.fContestId', type: 'text' },
|
||||||
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
{ value: 'srx', label: 'fltb.fSerialRcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
{ value: 'stx', label: 'fltb.fSerialSent', type: 'number' },
|
||||||
|
|||||||
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
|
|||||||
// impossible on any window wider than it is tall: one step out was already
|
// impossible on any window wider than it is tall: one step out was already
|
||||||
// too far in.
|
// too far in.
|
||||||
minZoom: 0,
|
minZoom: 0,
|
||||||
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
|
|
||||||
}).setView([20, 0], 2);
|
}).setView([20, 0], 2);
|
||||||
// The whole world, once, whatever the window is shaped like — computed by
|
// The whole world, once, whatever the window is shaped like — computed by
|
||||||
// Leaflet from the container rather than guessed with a zoom number.
|
// Leaflet from the container rather than guessed with a zoom number.
|
||||||
m.fitWorld({ animate: false });
|
m.fitWorld({ animate: false });
|
||||||
// Latitude only: the poles are the edge of the projection and there is
|
|
||||||
// nothing beyond them, while leaving longitude free keeps a drag from
|
|
||||||
// fighting the operator near the date line.
|
|
||||||
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
|
|
||||||
mapRef.current = m;
|
mapRef.current = m;
|
||||||
layerRef.current = L.layerGroup().addTo(m);
|
layerRef.current = L.layerGroup().addTo(m);
|
||||||
// Leaflet measures its container ONCE, when the map is created, and never
|
// Leaflet measures its container ONCE, when the map is created, and never
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -53,7 +53,13 @@ const ZERO: IcomState = {
|
|||||||
type Band = { l: string; hz: number };
|
type Band = { l: string; hz: number };
|
||||||
|
|
||||||
const HF_BANDS: Band[] = [
|
const HF_BANDS: Band[] = [
|
||||||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 },
|
||||||
|
// 60 m: the middle of the IARU Region 1 allocation (5351.5-5366.5 kHz), which
|
||||||
|
// every 60 m-capable rig can display. Where the band is channelised (the US)
|
||||||
|
// the operator moves to their channel from here — the button is a way onto
|
||||||
|
// the band, not a claim about what may be transmitted on it.
|
||||||
|
{ l: '60', hz: 5_354_000 },
|
||||||
|
{ l: '40', hz: 7_100_000 },
|
||||||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||||||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||||||
];
|
];
|
||||||
@@ -62,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
|||||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||||
|
|
||||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||||
// the plain SetFrequency command — no band-stacking codes needed.
|
// on the radio is asked where the operator last was instead, and one of these is
|
||||||
|
// only sent for a band or a model whose register cannot be read.
|
||||||
//
|
//
|
||||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||||
@@ -78,9 +85,36 @@ function bandsFor(model?: string): Band[] {
|
|||||||
return [...HF_BANDS, B6];
|
return [...HF_BANDS, B6];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
// Mode buttons for the console (like RS-BA1's row).
|
||||||
// SSB by frequency and the rig's data variant for digital modes.
|
//
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA'];
|
// LSB and USB by NAME, not one "SSB" button that resolves by band: the band
|
||||||
|
// convention is right for a logged mode and useless when the operator means
|
||||||
|
// "put this radio in USB on 40 m", which the console could not express at all.
|
||||||
|
//
|
||||||
|
// PSK is native only on the 7610/7760/7851 class; every other rig NAKs 0x12, so
|
||||||
|
// there the button is dead furniture — see modesFor. Soundcard PSK31 rides on
|
||||||
|
// DATA, which every rig can do.
|
||||||
|
const MODES_BASE = ['LSB', 'USB', 'CW', 'RTTY', 'AM', 'FM', 'DATA'];
|
||||||
|
|
||||||
|
function hasNativePSK(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700');
|
||||||
|
}
|
||||||
|
|
||||||
|
function modesFor(model?: string): string[] {
|
||||||
|
if (!hasNativePSK(model)) return MODES_BASE;
|
||||||
|
return [...MODES_BASE.slice(0, 4), 'PSK', ...MODES_BASE.slice(4)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which radios actually have an antenna selector on the CI-V command (0x12).
|
||||||
|
// An IC-7300 has ONE socket: offering it ANT1/ANT2 was two buttons that could
|
||||||
|
// only ever disagree with the front panel.
|
||||||
|
function hasAntennaSelector(model?: string): boolean {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
return m.includes('7610') || m.includes('7760') || m.includes('7851') ||
|
||||||
|
m.includes('7800') || m.includes('7700') || m.includes('9700');
|
||||||
|
}
|
||||||
|
|
||||||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
@@ -155,9 +189,21 @@ function icomWatts(pct: number): { w: number; defl: number } {
|
|||||||
return { w: Math.round(w), defl };
|
return { w: Math.round(w), defl };
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeMatches(btn: string, cur?: string): boolean {
|
// Which sideband a bare "SSB" means at this frequency — the same convention the
|
||||||
|
// backend applies when it resolves the mode for the radio.
|
||||||
|
function sideForHz(hz?: number): string | null {
|
||||||
|
if (!hz || hz <= 0) return null;
|
||||||
|
return hz < 10_000_000 ? 'LSB' : 'USB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeMatches(btn: string, cur?: string, hz?: number): boolean {
|
||||||
if (!cur) return false;
|
if (!cur) return false;
|
||||||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
// A rig that reports the folded ADIF "SSB" still lights the side its
|
||||||
|
// frequency implies, so the row is never blank on a phone contact.
|
||||||
|
if (btn === 'USB' || btn === 'LSB') {
|
||||||
|
if (cur === btn) return true;
|
||||||
|
return cur === 'SSB' && btn === (sideForHz(hz) ?? '');
|
||||||
|
}
|
||||||
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
||||||
// plain DATA — either way it is the DATA button that should light.
|
// plain DATA — either way it is the DATA button that should light.
|
||||||
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
||||||
@@ -347,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
const [tuning, setTuning] = useState(false);
|
const [tuning, setTuning] = useState(false);
|
||||||
|
// Band buttons: recall the radio's own band stacking register instead of
|
||||||
|
// sending a frequency picked here. Remembered per operator, not per session —
|
||||||
|
// it is a preference about how a button behaves, and having to set it again
|
||||||
|
// at every launch would make it not worth having.
|
||||||
|
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
|
||||||
|
const toggleBandStack = () => setBandStack((v) => {
|
||||||
|
const n = !v;
|
||||||
|
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
// Which register each band was last recalled from, so pressing the same band
|
||||||
|
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
|
||||||
|
const bandRegRef = useRef<Record<string, number>>({});
|
||||||
const txRef = useRef(false);
|
const txRef = useRef(false);
|
||||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
@@ -355,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||||
};
|
};
|
||||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||||
|
|
||||||
|
// A band button. With the stacking registers on, ask the radio where the
|
||||||
|
// operator last was on that band; pressing the band it is already on steps to
|
||||||
|
// the next register, and the fixed frequency below is the fallback for a band
|
||||||
|
// or a model whose register the backend will not read — never a dead button.
|
||||||
|
const bandClick = (b: Band, here: boolean) => {
|
||||||
|
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
|
||||||
|
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
|
||||||
|
IcomRecallBand(b.l, reg)
|
||||||
|
.then(() => { bandRegRef.current[b.l] = reg; load(); })
|
||||||
|
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
|
||||||
|
};
|
||||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -507,9 +578,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||||||
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60">
|
<div className="grid border-t border-border/60 divide-x divide-border/60"
|
||||||
{MODES.map((m) => {
|
style={{ gridTemplateColumns: `repeat(${modesFor(st.model).length}, minmax(0, 1fr))` }}>
|
||||||
const on = modeMatches(m, curMode);
|
{modesFor(st.model).map((m) => {
|
||||||
|
const on = modeMatches(m, curMode, mainHz);
|
||||||
return (
|
return (
|
||||||
<button key={m} type="button" onClick={() => setMode(m)}
|
<button key={m} type="button" onClick={() => setMode(m)}
|
||||||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||||||
@@ -546,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* Band buttons + antenna selection. */}
|
{/* Band buttons + antenna selection. */}
|
||||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||||
|
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
|
||||||
|
title={t('icmp.bandStackHint')}>
|
||||||
|
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
|
||||||
|
{t('icmp.bandStack')}
|
||||||
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{bandsFor(st.model).map((b) => {
|
{bandsFor(st.model).map((b) => {
|
||||||
const here = bandOfHz(mainHz) === b.l;
|
const here = bandOfHz(mainHz) === b.l;
|
||||||
return (
|
return (
|
||||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
|
||||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
here
|
here
|
||||||
@@ -561,10 +638,12 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Row label={t('icmp.antenna')}>
|
{hasAntennaSelector(st.model) && (
|
||||||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
<Row label={t('icmp.antenna')}>
|
||||||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||||||
</Row>
|
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
@@ -588,7 +667,10 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
|||||||
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
||||||
</span>
|
</span>
|
||||||
</Row>
|
</Row>
|
||||||
{isPhone && (
|
{/* Not phone-only: on USB-D the same control still sets what the radio
|
||||||
|
transmits at, and hiding it left an operator who lives in FT8 with
|
||||||
|
no mic gain at all. */}
|
||||||
|
{(
|
||||||
<Row label={t('icmp.mic')}>
|
<Row label={t('icmp.mic')}>
|
||||||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||||||
|
|||||||
@@ -370,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
if (from && to && arcPts) {
|
if (from && to && arcPts) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
|
||||||
|
// points framed a band of tile-less white above the top of the world.
|
||||||
|
// The line itself still draws to wherever it goes — only the CAMERA
|
||||||
|
// stays where there is a map to show.
|
||||||
|
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
|
||||||
|
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
|
||||||
|
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
} else if (to) {
|
} else if (to) {
|
||||||
wm.setView([to.lat, to.lon], 3);
|
wm.setView([to.lat, to.lon], 3);
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// PSKReporterPanel — can the station I am about to call actually hear me?
|
||||||
|
//
|
||||||
|
// The decodes list to the left says who is transmitting. It cannot say anything
|
||||||
|
// about the other direction, and on FT8 that is the whole question: the DX's
|
||||||
|
// pileup is invisible from here, and a station whose region is not open to
|
||||||
|
// yours will not hear you however many times you call.
|
||||||
|
//
|
||||||
|
// Every number here comes from PSK Reporter — reports uploaded by ordinary
|
||||||
|
// stations saying "I decoded X" — over a five-minute window. Nothing is
|
||||||
|
// inferred and nothing is remembered: when the window empties the panel says it
|
||||||
|
// does not know, which is the honest answer and the reason each block also says
|
||||||
|
// what it is measuring.
|
||||||
|
//
|
||||||
|
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Activity, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
export type PSKEntry = {
|
||||||
|
call: string;
|
||||||
|
grid?: string;
|
||||||
|
snr: number;
|
||||||
|
offset_hz: number;
|
||||||
|
age_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PSKAnalysis = {
|
||||||
|
target?: string;
|
||||||
|
mode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
spots: number;
|
||||||
|
he_me: boolean;
|
||||||
|
he_me_seconds: number;
|
||||||
|
he_me_snr: number;
|
||||||
|
he_me_offset_hz: number;
|
||||||
|
target_uploads: boolean;
|
||||||
|
target_grid?: string;
|
||||||
|
near_him_count: number;
|
||||||
|
near_him_top?: PSKEntry[];
|
||||||
|
from_my_area_count: number;
|
||||||
|
from_my_area_top?: PSKEntry[];
|
||||||
|
path_open: boolean;
|
||||||
|
heard_by_count: number;
|
||||||
|
heard_near_me: number;
|
||||||
|
heard_near_me_top?: PSKEntry[];
|
||||||
|
decoded_by_count: number;
|
||||||
|
decoded_by_top?: PSKEntry[];
|
||||||
|
decoded_by_calls?: string[];
|
||||||
|
pileup_count: number;
|
||||||
|
dial_hz: number;
|
||||||
|
ceiling_hz: number;
|
||||||
|
decodes_in_window: number;
|
||||||
|
bins?: { offset_hz: number; count: number; avg_snr: number }[];
|
||||||
|
suggested_offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// The station to analyse and the mode it was heard on. Set by clicking a
|
||||||
|
// decode, or by whoever the digital application says it is calling.
|
||||||
|
target: string;
|
||||||
|
mode?: string;
|
||||||
|
// The operator's own dial, which is what turns a report's frequency into an
|
||||||
|
// audio offset. Without it the passband block has nothing to say.
|
||||||
|
dialHz?: number;
|
||||||
|
// The local decodes, for "callers you hear": stations WE are decoding that
|
||||||
|
// are calling the same DX. That is the competition measured at this end,
|
||||||
|
// which no amount of PSK Reporter data can show.
|
||||||
|
callers: number;
|
||||||
|
callerCalls?: string[];
|
||||||
|
onCollapse: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
|
||||||
|
// have to match the backend's alignment exactly — it keys them on multiples of
|
||||||
|
// 60 from zero, so a strip starting at 200 would ask for edges that never
|
||||||
|
// exist and draw an empty histogram over a busy passband.
|
||||||
|
const LO = 200, HI = 4000, STEP = 60;
|
||||||
|
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
|
||||||
|
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
|
||||||
|
|
||||||
|
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [a, setA] = useState<PSKAnalysis | null>(null);
|
||||||
|
|
||||||
|
// One second, matching the panel's own claim about how fresh it is. The call
|
||||||
|
// is a snapshot of an in-memory window — no query and no network of its own.
|
||||||
|
useEffect(() => {
|
||||||
|
let stop = false;
|
||||||
|
const tick = () => {
|
||||||
|
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { stop = true; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The target is re-asserted rather than sent once. The backend treats an
|
||||||
|
// unchanged callsign as a no-op, and this way a broker that dropped while
|
||||||
|
// nobody was looking comes back on its own instead of leaving a panel that
|
||||||
|
// is permanently, silently empty.
|
||||||
|
useEffect(() => {
|
||||||
|
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
if (!target) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [target, mode, dialHz]);
|
||||||
|
|
||||||
|
const bins = a?.bins ?? [];
|
||||||
|
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
|
||||||
|
const byOffset = useMemo(() => {
|
||||||
|
const m = new Map<number, { count: number; avg_snr: number }>();
|
||||||
|
for (const b of bins) m.set(b.offset_hz, b);
|
||||||
|
return m;
|
||||||
|
}, [bins]);
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const out: { edge: number; count: number; snr: number | null }[] = [];
|
||||||
|
for (let i = 0; i < COLS; i++) {
|
||||||
|
const edge = FIRST_EDGE + i * STEP;
|
||||||
|
const b = byOffset.get(edge);
|
||||||
|
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [byOffset]);
|
||||||
|
|
||||||
|
// Confirmed pileup: a station we hear calling this DX that the DX has also
|
||||||
|
// decoded. Two independent pieces of evidence, so it is the one number here
|
||||||
|
// that is not a proxy for anything.
|
||||||
|
const confirmed = useMemo(() => {
|
||||||
|
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
|
||||||
|
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
|
||||||
|
}, [a?.decoded_by_calls, callerCalls]);
|
||||||
|
|
||||||
|
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
|
||||||
|
|
||||||
|
const Tile = ({ label, value, foot, tone, title }: {
|
||||||
|
label: string; value: number | string; foot: string; tone: string; title?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
||||||
|
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||||
|
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
||||||
|
{/* Header: what is being watched, and whether the feed is actually up. A
|
||||||
|
panel full of zeros means one of two very different things. */}
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
||||||
|
<Activity className="size-4 text-primary shrink-0" />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('psk.title')}
|
||||||
|
</span>
|
||||||
|
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
||||||
|
{a?.target && a.spots > 0 && (
|
||||||
|
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
||||||
|
)}
|
||||||
|
{a?.enabled === false
|
||||||
|
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
||||||
|
: a?.online
|
||||||
|
? <span className="text-success">● {t('psk.online')}</span>
|
||||||
|
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
||||||
|
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
||||||
|
{a?.enabled === false ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
||||||
|
) : !target ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── The answer ──────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
||||||
|
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
||||||
|
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
||||||
|
: 'bg-muted border-border text-muted-foreground')}>
|
||||||
|
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{a?.he_me ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
||||||
|
<div className="text-[11px] font-mono text-muted-foreground">
|
||||||
|
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : a?.path_open ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your signal reported next to him. Only when he has not decoded
|
||||||
|
you himself — that is strictly stronger evidence, and two
|
||||||
|
banners saying the same thing differently is noise. */}
|
||||||
|
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
||||||
|
✓ {t('psk.nearHim', { g: a.target_grid })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
||||||
|
{(a.near_him_top ?? []).map((h) => (
|
||||||
|
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
||||||
|
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── The four numbers ────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
||||||
|
tone="text-success"
|
||||||
|
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
||||||
|
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
||||||
|
tone="text-primary" title={t('psk.tPileupTip')} />
|
||||||
|
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
||||||
|
tone="text-info"
|
||||||
|
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
||||||
|
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
||||||
|
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
||||||
|
tone="text-warning" title={t('psk.tCallersTip')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The one thing that turns an empty panel from a verdict into a
|
||||||
|
missing measurement. */}
|
||||||
|
{a?.target_uploads ? (
|
||||||
|
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
||||||
|
⚠ {t('psk.noUploads', { c: target })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Who near you he is hearing ──────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
||||||
|
{(a?.from_my_area_top ?? []).length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||||
|
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
||||||
|
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
||||||
|
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
||||||
|
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
||||||
|
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
||||||
|
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
||||||
|
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
||||||
|
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── His passband ────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{(a?.ceiling_hz ?? 0) > 0
|
||||||
|
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
|
||||||
|
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
||||||
|
{columns.map((c) => {
|
||||||
|
const ratio = c.count / maxCount;
|
||||||
|
return (
|
||||||
|
<div key={c.edge}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-sm',
|
||||||
|
c.count === 0 ? 'bg-border'
|
||||||
|
: ratio > 0.66 ? 'bg-primary'
|
||||||
|
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
|
||||||
|
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
|
||||||
|
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
||||||
|
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
||||||
|
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
||||||
|
{[1000, 2000, 3000, 4000].map((hz) => (
|
||||||
|
<span key={hz} className="absolute whitespace-nowrap"
|
||||||
|
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
||||||
|
{hz}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(a?.suggested_offset ?? 0) > 0 && (
|
||||||
|
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
||||||
|
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { GetLoTWQSLDetail, SetLoTWQSLDetail, GetLoTWDownloadAllCalls, SetLoTWDownloadAllCalls, OpenExternalURL, FindQSOsForUpload, UploadFullLogHamQTH, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, ImportHamlogConfirmations, ExportHamlogUnmatched, OpenADIFFile, SaveADIFFile, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -42,6 +42,7 @@ const SERVICES = [
|
|||||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||||
|
{ v: 'hamqth', label: 'HamQTH' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
];
|
];
|
||||||
@@ -722,7 +723,25 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
{service !== 'pota' && service !== 'paper' && (
|
{service !== 'pota' && service !== 'paper' && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{service === 'hamlog' ? (
|
{service === 'hamqth' ? (
|
||||||
|
// HamQTH's file endpoint REPLACES the remote log — its own
|
||||||
|
// documentation is explicit that partial uploads do not exist. So
|
||||||
|
// it is offered as its own deliberate act, never as the batch path
|
||||||
|
// behind "send these": that would delete everything not selected.
|
||||||
|
<Button variant="outline" size="sm" disabled={busy}
|
||||||
|
title={t('qslm.hqFullTitle')}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(t('qslm.hqFullConfirm'))) return;
|
||||||
|
// Same three lines every other action here runs: without
|
||||||
|
// setShowLog the whole upload reported itself into a panel
|
||||||
|
// nobody was showing, and the tab sat on "Pick a service".
|
||||||
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
|
try { await UploadFullLogHamQTH(); }
|
||||||
|
catch (e: any) { setBusy(false); setLogLines((l) => [...l, String(e?.message ?? e)]); }
|
||||||
|
}}>
|
||||||
|
<UploadCloud className="size-3.5" /> {t('qslm.hqFull')}
|
||||||
|
</Button>
|
||||||
|
) : service === 'hamlog' ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
<Button variant="outline" size="sm" onClick={importHamlogCfm} disabled={busy}
|
||||||
title={t('qslm.hamlogImportTitle')}>
|
title={t('qslm.hamlogImportTitle')}>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
{ service: 'hrdlog', name: 'HRDLog.net' },
|
{ service: 'hrdlog', name: 'HRDLog.net' },
|
||||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||||
{ service: 'lotw', name: 'LoTW' },
|
{ service: 'lotw', name: 'LoTW' },
|
||||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
{ service: 'hamqth', name: 'HamQTH' },
|
||||||
|
{ service: 'cloudlog', name: 'Cloudlog / Wavelog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandForMHz } from '@/lib/bandplan';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
import type { QSOForm } from '@/types';
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ const CONFIRMATIONS: ConfDef[] = [
|
|||||||
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
|
||||||
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
|
||||||
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
|
||||||
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
|
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any, rcvd: 'clublog_qso_download_status' as any, rcvdDate: 'clublog_qso_download_date' as any },
|
||||||
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
|
||||||
];
|
];
|
||||||
// i18n label keys for confirmation channels whose label has translatable words
|
// i18n label keys for confirmation channels whose label has translatable words
|
||||||
@@ -80,6 +81,19 @@ const CONF_LABEL_KEYS: Record<string, string> = {
|
|||||||
QSL: 'qedit.confQslPaper',
|
QSL: 'qedit.confQslPaper',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reading order for the channel list: the two that carry an ARRL award first —
|
||||||
|
// paper QSL and LoTW — then everything else alphabetically, so a channel is
|
||||||
|
// found by its name rather than by remembering the order it was added in.
|
||||||
|
const CONF_FIRST: Record<string, number> = { QSL: 0, LOTW: 1 };
|
||||||
|
function confOrder<T extends { key: string; label: string }>(rows: T[]): T[] {
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const ra = CONF_FIRST[a.key] ?? 2;
|
||||||
|
const rb = CONF_FIRST[b.key] ?? 2;
|
||||||
|
if (ra !== rb) return ra - rb;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
// OpsLog's own card. Kept out of CONFIRMATIONS on purpose — that list maps QSO
|
||||||
// columns and this channel is backed by ADIF extras — but it still belongs in
|
// columns and this channel is backed by ADIF extras — but it still belongs in
|
||||||
// the channel picker and the status table alongside the rest.
|
// the channel picker and the status table alongside the rest.
|
||||||
@@ -98,6 +112,15 @@ const HAMLOG_KEYS = {
|
|||||||
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
rcvdDate: 'APP_OPSLOG_HAMLOG_QSL_DATE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// HamQTH — extras again (ADIF names no HamQTH field), and SENT only: the site
|
||||||
|
// publishes no confirmation feed, so a received column here would be a promise
|
||||||
|
// nothing can keep.
|
||||||
|
const HAMQTH_CONF = 'HAMQTH';
|
||||||
|
const HAMQTH_KEYS = {
|
||||||
|
sent: 'APP_OPSLOG_HAMQTH_SENT',
|
||||||
|
sentDate: 'APP_OPSLOG_HAMQTH_SENT_DATE',
|
||||||
|
};
|
||||||
|
|
||||||
// Colour-coded status cell for the confirmation grid.
|
// Colour-coded status cell for the confirmation grid.
|
||||||
function StatusCell({ value }: { value?: string }) {
|
function StatusCell({ value }: { value?: string }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -290,6 +313,19 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
const splitHz = (hz?: number) => hz
|
const splitHz = (hz?: number) => hz
|
||||||
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
|
||||||
: { khz: '', hz: '' };
|
: { khz: '', hz: '' };
|
||||||
|
// Correcting a frequency corrects its band. The pair has to agree — the log,
|
||||||
|
// every award and every upload are read on the BAND — and an operator fixing
|
||||||
|
// a wrong frequency is not also expecting to fix the band by hand, which is
|
||||||
|
// exactly how a QSO ends up filed on 20m at 7 MHz.
|
||||||
|
//
|
||||||
|
// Only when the number lands in a known allocation: half a frequency is typed
|
||||||
|
// on the way to all of it, and a band must never be blanked by that.
|
||||||
|
const syncBand = (khz: string, hz: string, field: 'band' | 'band_rx') => {
|
||||||
|
if (!khz.trim()) return;
|
||||||
|
const b = bandForMHz((parseInt(khz, 10) * 1000 + (parseInt(hz, 10) || 0)) / 1_000_000);
|
||||||
|
if (b) set(field, b as any);
|
||||||
|
};
|
||||||
|
|
||||||
const f0 = splitHz(draft.freq_hz);
|
const f0 = splitHz(draft.freq_hz);
|
||||||
const fr0 = splitHz(draft.freq_rx_hz);
|
const fr0 = splitHz(draft.freq_rx_hz);
|
||||||
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
const [freqKHz, setFreqKHz] = useState(f0.khz);
|
||||||
@@ -612,13 +648,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.txFreq')}</Label>
|
||||||
<Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqKHz(v); syncBand(v, freqHz, 'band'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqHz(v); syncBand(freqKHz, v, 'band'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
<Label className="w-20 shrink-0">{t('qedit.rxFreq')}</Label>
|
||||||
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
|
<Input value={freqRxKHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxKHz(v); syncBand(v, freqRxHz, 'band_rx'); }} className="font-mono w-24" placeholder="kHz" />
|
||||||
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
<Input value={freqRxHz} onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); setFreqRxHz(v); syncBand(freqRxKHz, v, 'band_rx'); }} maxLength={3} className="font-mono w-16" placeholder="Hz" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,19 +783,34 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
<Select value={confSel} onValueChange={setConfSel}>
|
<Select value={confSel} onValueChange={setConfSel}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</SelectItem>)}
|
{confOrder([
|
||||||
{/* Listed here but NOT in CONFIRMATIONS: that table maps
|
...CONFIRMATIONS.map((c) => ({ key: c.key, label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label })),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog') },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online' },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH' },
|
||||||
|
]).map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}
|
||||||
|
{/* The three above that are NOT in CONFIRMATIONS — the
|
||||||
QSO columns, and this channel lives in the ADIF
|
QSO columns, and this channel lives in the ADIF
|
||||||
extras. It gets its own editor below rather than the
|
OpsLog card, HAMLOG.online and HamQTH — are backed by
|
||||||
generic sent/received/date grid, which has no field
|
ADIF extras rather than QSO columns, and each gets its
|
||||||
to bind to. */}
|
own editor below instead of the generic
|
||||||
<SelectItem value={OPSLOG_CONF}>{t('qedit.confOpsLog')}</SelectItem>
|
sent/received/date grid, which has no field to bind
|
||||||
<SelectItem value={HAMLOG_CONF}>HAMLOG.online</SelectItem>
|
to. */}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confSel === HAMLOG_CONF ? (
|
{confSel === HAMQTH_CONF ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMQTH_KEYS.sent)} onChange={(v) => exPut(HAMQTH_KEYS.sent, v)} /></div>
|
||||||
|
<div><Label>{t('qedit.dateSent')}</Label><AdifDateInput value={exVal(HAMQTH_KEYS.sentDate)} onChange={(v) => exPut(HAMQTH_KEYS.sentDate, v)} /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('qedit.qslPanelHint')} <strong>{t('qedit.saveChanges')}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : confSel === HAMLOG_CONF ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
<div><Label>{t('qedit.sent')}</Label><QslSelect value={exVal(HAMLOG_KEYS.sent)} onChange={(v) => exPut(HAMLOG_KEYS.sent, v)} /></div>
|
||||||
@@ -845,30 +896,32 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{CONFIRMATIONS.map((c) => (
|
{/* The extras-backed channels (OpsLog card,
|
||||||
|
HAMLOG.online, HamQTH) sit in the same ordered list
|
||||||
|
as the column-backed ones: the reader is looking for
|
||||||
|
a name, not for a storage detail. A dash in RECEIVED
|
||||||
|
means the channel has nothing to receive — Club Log
|
||||||
|
and HamQTH publish no confirmations — which is not
|
||||||
|
the same statement as "N". */}
|
||||||
|
{confOrder([
|
||||||
|
...CONFIRMATIONS.map((c) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label,
|
||||||
|
sent: val(c.sent),
|
||||||
|
rcvd: c.rcvd ? val(c.rcvd) : null,
|
||||||
|
})),
|
||||||
|
{ key: OPSLOG_CONF, label: t('qedit.confOpsLog'), sent: opslogQslSent ? 'Y' : 'N', rcvd: qslReceived ? 'Y' : 'N' },
|
||||||
|
{ key: HAMLOG_CONF, label: 'HAMLOG.online', sent: exVal(HAMLOG_KEYS.sent), rcvd: exVal(HAMLOG_KEYS.rcvd) },
|
||||||
|
{ key: HAMQTH_CONF, label: 'HamQTH', sent: exVal(HAMQTH_KEYS.sent), rcvd: null },
|
||||||
|
]).map((c) => (
|
||||||
<tr key={c.key} className="text-xs">
|
<tr key={c.key} className="text-xs">
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
|
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{c.label}</td>
|
||||||
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
|
<td className="w-24"><StatusCell value={c.sent} /></td>
|
||||||
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground">—</span>}</td>
|
<td className="w-24">{c.rcvd === null
|
||||||
|
? <span className="block text-center text-[11px] text-muted-foreground">—</span>
|
||||||
|
: <StatusCell value={c.rcvd} />}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{/* OpsLog's own card, read from the ADIF extras rather
|
|
||||||
than a QSO column — hence a hand-written row instead
|
|
||||||
of a CONFIRMATIONS entry. "Sent" is stamped by OpsLog
|
|
||||||
when the card actually goes out, so it stays
|
|
||||||
read-only here: an operator ticking it by hand would
|
|
||||||
be recording something that never happened. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{t('qedit.confOpsLog')}</td>
|
|
||||||
<td className="w-24"><StatusCell value={opslogQslSent ? 'Y' : 'N'} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={qslReceived ? 'Y' : 'N'} /></td>
|
|
||||||
</tr>
|
|
||||||
{/* HAMLOG.online — extras again, same hand-written row. */}
|
|
||||||
<tr className="text-xs">
|
|
||||||
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">HAMLOG.online</td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.sent)} /></td>
|
|
||||||
<td className="w-24"><StatusCell value={exVal(HAMLOG_KEYS.rcvd)} /></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_sent_date'), colId: 'hamlog_sent_date', headerName: t('rqg.h.hamlog_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_SENT_DATE']), defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd'), colId: 'hamlog_rcvd', headerName: t('rqg.h.hamlog_rcvd'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_HAMLOG_QSO_CFM'] || e['APP_OPSLOG_HAMLOG_QSL'] || 'N'; }, defaultVisible: false },
|
||||||
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
{ group: 'Uploads', label: t('rqg.c.hamlog_rcvd_date'), colId: 'hamlog_rcvd_date', headerName: t('rqg.h.hamlog_rcvd_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMLOG_QSL_DATE']), defaultVisible: false },
|
||||||
|
// HamQTH, the extras again — sent only, the site having no confirmations.
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent'), colId: 'hamqth_sent', headerName: t('rqg.h.hamqth_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_HAMQTH_SENT'] || 'N'; }, defaultVisible: false },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.hamqth_sent_date'), colId: 'hamqth_sent_date', headerName: t('rqg.h.hamqth_sent_date'), width: 110, valueGetter: (p) => fmtDateOnly((p.data as any)?.extras?.['APP_OPSLOG_HAMQTH_SENT_DATE']), defaultVisible: false },
|
||||||
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
// App-specific: when the QSO's audio recording was e-mailed to the station.
|
||||||
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
{ group: 'QSL', label: t('rqg.c.opslog_recording_sent'), colId: 'opslog_recording_sent', headerName: t('rqg.h.opslog_recording_sent'), width: 100, cellClass: qslStatusCellClass, valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
|
||||||
|
|
||||||
@@ -235,6 +238,8 @@ export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
|||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd'), colId: 'qrzcom_qso_download_status', headerName: t('rqg.c.qrz_rcvd'), field: 'qrzcom_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_sent_date'), colId: 'qrzcom_qso_upload_date', headerName: t('rqg.h.qrz_sent_date'), field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
{ group: 'Uploads', label: t('rqg.c.qrz_rcvd_date'), colId: 'qrzcom_qso_download_date', headerName: t('rqg.h.qrz_rcvd_date'), field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd'), colId: 'clublog_qso_download_status', headerName: t('rqg.c.clublog_rcvd'), field: 'clublog_qso_download_status' as any, width: 100 , cellClass: qslStatusCellClass },
|
||||||
|
{ group: 'Uploads', label: t('rqg.c.clublog_rcvd_date'), colId: 'clublog_qso_download_date', headerName: t('rqg.h.clublog_rcvd_date'), field: 'clublog_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||||
|
|
||||||
// ── Contest ──
|
// ── Contest ──
|
||||||
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
{ group: 'Contest', label: t('rqg.c.contest_id'), colId: 'contest_id', headerName: t('rqg.h.contest_id'), field: 'contest_id' as any, width: 110 },
|
||||||
@@ -784,7 +789,11 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
|
||||||
{GROUP_ORDER.map((group) => {
|
{GROUP_ORDER.map((group) => {
|
||||||
const cols = COL_CATALOG.filter((c) => c.group === group);
|
// Alphabetical within the group: the catalog's order is the
|
||||||
|
// GRID's column order, which appends newcomers at the end — so
|
||||||
|
// the two ClubLog rows sat at opposite ends of the Uploads box.
|
||||||
|
const cols = COL_CATALOG.filter((c) => c.group === group)
|
||||||
|
.slice().sort((a, b) => (a.label ?? '').localeCompare(b.label ?? ''));
|
||||||
if (cols.length === 0) return null;
|
if (cols.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={group} className="rounded-md border border-border p-2.5">
|
<div key={group} className="rounded-md border border-border p-2.5">
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
|
||||||
|
GetChaseSettings, SaveChaseSettings,
|
||||||
GetAudioMonitorPref,
|
GetAudioMonitorPref,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
@@ -44,11 +45,11 @@ import {
|
|||||||
SetCIVTrace,
|
SetCIVTrace,
|
||||||
CIVTraceEnabled,
|
CIVTraceEnabled,
|
||||||
WinkeyerTraceEnabled,
|
WinkeyerTraceEnabled,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
GetScpStatus, SetScpEnabled, DownloadScp,
|
GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
|
||||||
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
|
||||||
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
|
||||||
@@ -59,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, 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';
|
||||||
@@ -82,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';
|
||||||
@@ -212,6 +214,7 @@ type SectionId =
|
|||||||
| 'lists-modes'
|
| 'lists-modes'
|
||||||
| 'lists-satellites'
|
| 'lists-satellites'
|
||||||
| 'cluster'
|
| 'cluster'
|
||||||
|
| 'dxhunter'
|
||||||
| 'backup'
|
| 'backup'
|
||||||
| 'database'
|
| 'database'
|
||||||
| 'autostart'
|
| 'autostart'
|
||||||
@@ -324,6 +327,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
|
{ kind: 'item', label: t('sec.satellites'), id: 'lists-satellites' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||||
|
{ kind: 'item', label: t('sec.dxhunter'), id: 'dxhunter' },
|
||||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||||
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||||
@@ -402,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,
|
||||||
@@ -469,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' },
|
||||||
@@ -481,6 +495,8 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
|
|||||||
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
|
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
|
||||||
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
|
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
|
||||||
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
|
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
|
||||||
|
'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
|
||||||
|
'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
|
||||||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1611,7 +1627,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
|
||||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
|
||||||
});
|
});
|
||||||
// Brand + connection, derived from the stored backend rather than held
|
// Brand + connection, derived from the stored backend rather than held
|
||||||
// separately: two sources for one fact drift apart the first time something
|
// separately: two sources for one fact drift apart the first time something
|
||||||
@@ -1699,13 +1715,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
type AudioSettings = {
|
type AudioSettings = {
|
||||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
|
||||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||||
};
|
};
|
||||||
type AudioDev = { id: string; name: string; default: boolean };
|
type AudioDev = { id: string; name: string; default: boolean };
|
||||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
|
||||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||||
});
|
});
|
||||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||||
@@ -1718,6 +1734,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// DVK voice-keyer messages (F1–F6).
|
// DVK voice-keyer messages (F1–F6).
|
||||||
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
|
||||||
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
|
||||||
|
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
|
||||||
|
useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
|
||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
const [dvkErr, setDvkErr] = useState('');
|
const [dvkErr, setDvkErr] = useState('');
|
||||||
@@ -1748,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');
|
||||||
@@ -1819,17 +1838,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
qsl_sent: string; qsl_rcvd: string;
|
qsl_sent: string; qsl_rcvd: string;
|
||||||
lotw_sent: string; lotw_rcvd: string;
|
lotw_sent: string; lotw_rcvd: string;
|
||||||
eqsl_sent: string; eqsl_rcvd: string;
|
eqsl_sent: string; eqsl_rcvd: string;
|
||||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
|
||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string; hamlog_confirmed: string;
|
hamlog_status: string; hamlog_confirmed: string;
|
||||||
|
hamqth_status: string;
|
||||||
};
|
};
|
||||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||||
qsl_sent: '', qsl_rcvd: '',
|
qsl_sent: '', qsl_rcvd: '',
|
||||||
lotw_sent: '', lotw_rcvd: '',
|
lotw_sent: '', lotw_rcvd: '',
|
||||||
eqsl_sent: '', eqsl_rcvd: '',
|
eqsl_sent: '', eqsl_rcvd: '',
|
||||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
|
||||||
qrzcom_confirmed: '',
|
qrzcom_confirmed: '',
|
||||||
hamlog_status: '', hamlog_confirmed: '',
|
hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// External services (logbook upload). One block per service; only QRZ is
|
// External services (logbook upload). One block per service; only QRZ is
|
||||||
@@ -1845,7 +1865,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||||
on_air?: boolean;
|
on_air?: boolean;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
@@ -1853,7 +1873,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -1881,6 +1901,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||||
finally { setScpBusy(false); }
|
finally { setScpBusy(false); }
|
||||||
};
|
};
|
||||||
|
const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
|
||||||
const downloadScp = async () => {
|
const downloadScp = async () => {
|
||||||
setScpBusy(true);
|
setScpBusy(true);
|
||||||
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
|
||||||
@@ -2021,6 +2042,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||||
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hamlogTesting, setHamlogTesting] = useState(false);
|
const [hamlogTesting, setHamlogTesting] = useState(false);
|
||||||
|
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [hamqthTesting, setHamqthTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [eqslTesting, setEqslTesting] = useState(false);
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
@@ -2028,7 +2051,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||||||
// stays put because moving it down would reset the tab on every reopen —
|
// stays put because moving it down would reset the tab on every reopen —
|
||||||
// a choice now, not a workaround.
|
// a choice now, not a workaround.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -2072,8 +2095,33 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
};
|
};
|
||||||
const [chaseGrids, setChaseGrids] = useState(false);
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
|
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
|
||||||
|
const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
|
||||||
|
const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
|
||||||
|
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
|
||||||
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 [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
|
||||||
|
// multi-line list, and normalising it on every keystroke would fight the
|
||||||
|
// Return key — the one key this box is built around.
|
||||||
|
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 saveAC = async (next: any) => {
|
||||||
|
setAc(next);
|
||||||
|
try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
|
||||||
|
};
|
||||||
|
const savePSKTgt = async (next: any) => {
|
||||||
|
setPskTgt(next);
|
||||||
|
try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
|
||||||
|
};
|
||||||
const [spotTTL, setSpotTTL] = useState(0);
|
const [spotTTL, setSpotTTL] = useState(0);
|
||||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||||
const [spotMaxText, setSpotMaxText] = useState('1000');
|
const [spotMaxText, setSpotMaxText] = useState('1000');
|
||||||
@@ -2093,8 +2141,19 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
try {
|
||||||
|
const g = await GetChaseNewGrids();
|
||||||
|
setChaseGrids(g);
|
||||||
|
// Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
|
||||||
|
// gates NEW GRID synchronously from localStorage.
|
||||||
|
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
|
||||||
|
} catch { /* defaults stand */ }
|
||||||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||||
|
try { setPskTgt(await GetPSKTargetSettings()); } 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 { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
|
||||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||||
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
|
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
|
||||||
@@ -3825,6 +3884,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label> </>
|
</label> </>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
|
||||||
|
<Checkbox
|
||||||
|
checked={!!catCfg.digi_as_usb}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
|
||||||
|
/>
|
||||||
|
{t('cat.digiUsb')}
|
||||||
|
</label>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -4416,8 +4482,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Baud</Label>
|
<Label>Baud</Label>
|
||||||
<Input type="number" min={1200} value={amp.baud}
|
{/* A list, not a free number: the KPA500 report that
|
||||||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
began this had its operator wondering whether a typed
|
||||||
|
baud was the whole problem. These are the rates the
|
||||||
|
supported amplifiers actually speak. */}
|
||||||
|
<select value={String(amp.baud)}
|
||||||
|
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
|
||||||
|
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
|
||||||
|
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
||||||
|
<option key={b} value={String(b)}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -4800,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'); }} />
|
||||||
@@ -5205,6 +5294,302 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
setEditingServer(next);
|
setEditingServer(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chasing is not a cluster feature.
|
||||||
|
//
|
||||||
|
// These switches were born on the DX Cluster page because the cluster was the
|
||||||
|
// only thing that drew their badges. They now decide what the FT decode list
|
||||||
|
// and Chase new say as well, and settings that govern three screens do not
|
||||||
|
// belong under the name of one of them. The page is called DXHunter because
|
||||||
|
// that is what it is about — hunting DX — and because more of DXHunter's
|
||||||
|
// ideas are meant to land beside them.
|
||||||
|
function DXHunterPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
||||||
|
once — badge, colour, filter chip, reference column. A "new band
|
||||||
|
+ new POTA" spot then reads NEW BAND alone. */}
|
||||||
|
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
|
||||||
|
county, state, grid) judged as new-only or new-plus-unconfirmed,
|
||||||
|
against the confirmation sources the operator trusts. */}
|
||||||
|
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
|
||||||
|
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
||||||
|
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{chaseCfg.mode === 'new_unconfirmed' && (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap pl-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
|
||||||
|
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
|
||||||
|
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox checked={chaseCfg.sources.includes(k)}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
|
||||||
|
const next = { ...chaseCfg, sources };
|
||||||
|
setChaseCfg(next); void SaveChaseSettings(next as any);
|
||||||
|
}} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
||||||
|
<Checkbox checked={chasePotaOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chasePota')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
|
||||||
|
<Checkbox checked={chaseSotaOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseSota')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
|
||||||
|
<Checkbox checked={chaseCountyOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseCounty')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
|
||||||
|
<Checkbox checked={chasePfxOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chasePfx')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
|
||||||
|
<Checkbox checked={chaseStateOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseState')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{chaseGrids && (
|
||||||
|
<p className="pl-6 text-xs text-muted-foreground">
|
||||||
|
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
||||||
|
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What counts as "I already have this square". There is no single
|
||||||
|
right answer — a VUCC chaser counts a square per band, someone
|
||||||
|
filling a wall map counts it once — so it is a choice, and the
|
||||||
|
same six GridTracker offers, because a square wanted in one and
|
||||||
|
not the other is a bug report every time. */}
|
||||||
|
<div className="pl-6 space-y-1.5 pt-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
||||||
|
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(gridScope.scopes ?? []).map((s: any) => (
|
||||||
|
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||||
|
squares and chasing entities are different wants; they only share
|
||||||
|
the PSK Reporter feed, which either one brings up. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={chaseNew} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||||
|
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||||
|
</label>
|
||||||
|
{/* The same radius the band-opening watch uses — one feed, one
|
||||||
|
circle — but reachable from here, because an operator who only
|
||||||
|
wants the chase list would otherwise have to find it inside a
|
||||||
|
watch they never switched on. It is the setting that decides
|
||||||
|
whether this list has anything in it at all: where stations are
|
||||||
|
far apart, 300 km can hold no receivers whatsoever. */}
|
||||||
|
{chaseNew && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap pl-6">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||||
|
<Input
|
||||||
|
type="number" min={25} max={3000} step={25}
|
||||||
|
className="w-24 h-7 text-xs"
|
||||||
|
defaultValue={bandOpen.near_km ?? 300}
|
||||||
|
key={`cnk-${bandOpen.near_km ?? 300}`}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">km</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* PSK Reporter analysis of the station being called. Same service as
|
||||||
|
the two options above, opposite question: those ask what is being
|
||||||
|
heard around here, this asks whether ONE station can hear you. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={pskTgt.enabled} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
|
||||||
|
<span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{pskTgt.enabled && (
|
||||||
|
<div className="pl-6 space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
|
||||||
|
<Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
|
||||||
|
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
|
||||||
|
<SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contest — how a special-event fleet finds its way onto the watch
|
||||||
|
list on its own. Two halves, because a fleet has two kinds of
|
||||||
|
member. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<div className="text-sm font-medium">{t('wlc.title')}</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
|
||||||
|
<Input className="h-7 w-32 text-xs font-mono uppercase"
|
||||||
|
defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
|
||||||
|
placeholder={t('wlc.patternPh')}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value.toUpperCase().trim();
|
||||||
|
if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
|
||||||
|
value={contestCalls}
|
||||||
|
placeholder={t('wlc.callsPh')}
|
||||||
|
onChange={(e) => setContestCalls(e.target.value)}
|
||||||
|
onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-call. Last in this section, and behind a warning: it is the
|
||||||
|
only setting in OpsLog that transmits without being asked to. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={ac.enabled} className="mt-0.5"
|
||||||
|
onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
|
||||||
|
<span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-warning">{t('ac.warn')}</p>
|
||||||
|
{ac.enabled && (
|
||||||
|
<div className="pl-6 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
|
||||||
|
{/* A LIST: several callsigns, spaces or commas. Committed on
|
||||||
|
blur or Enter and kept as raw text while typing — binding the
|
||||||
|
box to the parsed value is what makes the space key look dead,
|
||||||
|
and space is the one key this field needs. */}
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
|
||||||
|
<Input className="h-7 w-72 text-xs font-mono uppercase"
|
||||||
|
defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
|
||||||
|
placeholder={t('ac.onlyPh')}
|
||||||
|
onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||||
|
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{([
|
||||||
|
['attempts', t('ac.attempts'), 1, 30],
|
||||||
|
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
|
||||||
|
['misses', t('ac.misses'), 1, 10],
|
||||||
|
['max_rounds', t('ac.rounds'), 1, 10],
|
||||||
|
['rest_min', t('ac.rest'), 1, 60],
|
||||||
|
] as [string, string, number, number][]).map(([k, label, min, max]) => (
|
||||||
|
<span key={k} className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
|
||||||
|
defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClusterPanel() {
|
function ClusterPanel() {
|
||||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||||
// Written on every keystroke. This panel has no Save button, and a pair of
|
// Written on every keystroke. This panel has no Save button, and a pair of
|
||||||
@@ -5407,73 +5792,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
|
||||||
{/* Chase switches: off, the marker stops being TOLD, everywhere at
|
|
||||||
once — badge, colour, filter chip, reference column. A "new band
|
|
||||||
+ new POTA" spot then reads NEW BAND alone. */}
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
|
|
||||||
<Checkbox checked={chasePotaOn}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
|
|
||||||
{t('clu.chasePota')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
|
|
||||||
<Checkbox checked={chaseSotaOn}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
|
|
||||||
{t('clu.chaseSota')}
|
|
||||||
</label>
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
|
||||||
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
|
||||||
<span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
|
|
||||||
</label>
|
|
||||||
{chaseGrids && (
|
|
||||||
<p className="pl-6 text-xs text-muted-foreground">
|
|
||||||
{t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
|
|
||||||
{pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* What counts as "I already have this square". There is no single
|
|
||||||
right answer — a VUCC chaser counts a square per band, someone
|
|
||||||
filling a wall map counts it once — so it is a choice, and the
|
|
||||||
same six GridTracker offers, because a square wanted in one and
|
|
||||||
not the other is a bug report every time. */}
|
|
||||||
<div className="pl-6 space-y-1.5 pt-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
|
|
||||||
<Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
|
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(gridScope.scopes ?? []).map((s: any) => (
|
|
||||||
<SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
|
|
||||||
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
|
|
||||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
|
|
||||||
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
|
||||||
squares and chasing entities are different wants; they only share
|
|
||||||
the PSK Reporter feed, which either one brings up. */}
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={chaseNew} className="mt-0.5"
|
|
||||||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
|
||||||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||||
switching it on adds two of them — the operator should see that
|
switching it on adds two of them — the operator should see that
|
||||||
happen where it happens rather than find nodes they did not add. */}
|
happen where it happens rather than find nodes they did not add. */}
|
||||||
@@ -5509,7 +5827,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||||
<Input
|
<Input
|
||||||
type="number" min={25} max={1000} step={25}
|
type="number" min={25} max={3000} step={25}
|
||||||
className="w-24 h-7 text-xs"
|
className="w-24 h-7 text-xs"
|
||||||
defaultValue={bandOpen.near_km ?? 300}
|
defaultValue={bandOpen.near_km ?? 300}
|
||||||
key={`nk-${bandOpen.near_km ?? 300}`}
|
key={`nk-${bandOpen.near_km ?? 300}`}
|
||||||
@@ -5672,7 +5990,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||||
{renderSelect('clublog_status', FULL_OPTIONS)}
|
{renderSelect('clublog_status', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
|
||||||
|
{renderSelect('clublog_confirmed', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* HRDLog */}
|
{/* HRDLog */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
@@ -5695,6 +6016,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* HamQTH — no received side: the site has no confirmation feed. */}
|
||||||
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
|
<Label className="text-sm font-medium pb-1.5">HamQTH</Label>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
|
||||||
|
{renderSelect('hamqth_status', FULL_OPTIONS)}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
{/* HAMLOG.online */}
|
{/* HAMLOG.online */}
|
||||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||||
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
<Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
|
||||||
@@ -5716,10 +6046,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
function UDPIntegrationsPanelWrapper() {
|
function UDPIntegrationsPanelWrapper() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title={t('sec.udp')} />
|
||||||
title={t('sec.udp')}
|
|
||||||
hint={t('udp.hint')}
|
|
||||||
/>
|
|
||||||
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -5891,6 +6218,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||||
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
||||||
|
{ k: 'hamqth', label: 'HAMQTH', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -5960,6 +6288,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hamqth = extSvc.hamqth ?? emptyExtCfg();
|
||||||
|
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
|
||||||
|
async function testHamqth() {
|
||||||
|
setHamqthTesting(true);
|
||||||
|
setHamqthTest(null);
|
||||||
|
try {
|
||||||
|
const msg = await TestHamQTHUpload();
|
||||||
|
setHamqthTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setHamqthTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
||||||
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||||||
@@ -6305,11 +6648,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{/* The site stopped issuing API keys and its upload API takes
|
||||||
<Checkbox
|
nothing else, so an auto-upload switch here would arm something
|
||||||
checked={hamlog.auto_upload}
|
that can only fail. Their confirmations still arrive as a file,
|
||||||
onCheckedChange={(c) => setHamlog({ auto_upload: !!c })}
|
which never needed a key. */}
|
||||||
/>
|
<p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
|
||||||
|
{t('es.hamlogClosed')}
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
|
||||||
|
<Checkbox checked={false} disabled />
|
||||||
{t('es.autoUpload')}
|
{t('es.autoUpload')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -6339,6 +6686,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'hamqth' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.username')}</Label>
|
||||||
|
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
|
||||||
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
|
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
|
||||||
|
<Label className="text-sm">{t('es.hamqthCall')}</Label>
|
||||||
|
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
|
||||||
|
{t('es.autoUpload')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||||
|
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||||
|
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
|
||||||
|
</Button>
|
||||||
|
{hamqthTest && (
|
||||||
|
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'cloudlog' ? (
|
) : extSvcTab === 'cloudlog' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
@@ -7097,6 +7481,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Kenwood only, because only a Kenwood acts on it: TX1 is that
|
||||||
|
family's second transmit command. Every other backend keys the
|
||||||
|
one way it knows, so showing the box there would be a switch
|
||||||
|
that changes nothing — the same dead furniture as ANT2 on a
|
||||||
|
radio with one socket. */}
|
||||||
|
{audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
|
||||||
|
<>
|
||||||
|
<span />
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
|
||||||
|
<Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
|
||||||
|
onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
|
||||||
|
<span>{t('aud.pttData')}</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||||
<>
|
<>
|
||||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||||
@@ -7172,6 +7571,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
>
|
>
|
||||||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
|
||||||
|
title={t('aud.deleteMsg')}
|
||||||
|
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
|
||||||
|
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -7264,6 +7672,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||||
{t('scp.enable')}
|
{t('scp.enable')}
|
||||||
</label>
|
</label>
|
||||||
|
{scp.enabled && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
|
||||||
|
<Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
|
||||||
|
onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
|
||||||
|
{t('scp.clublog')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{scp.enabled && (
|
{scp.enabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||||
@@ -7900,6 +8315,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
'lists-modes': ModesPanel,
|
'lists-modes': ModesPanel,
|
||||||
'lists-satellites': SatellitesPanel,
|
'lists-satellites': SatellitesPanel,
|
||||||
cluster: ClusterPanel,
|
cluster: ClusterPanel,
|
||||||
|
dxhunter: DXHunterPanel,
|
||||||
udp: UDPIntegrationsPanelWrapper,
|
udp: UDPIntegrationsPanelWrapper,
|
||||||
// Module-scope components, wrapped so their props can be passed. The nested
|
// Module-scope components, wrapped so their props can be passed. The nested
|
||||||
// panels below go through PanelHost instead — which is what now lets either
|
// panels below go through PanelHost instead — which is what now lets either
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||||
|
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -158,6 +159,15 @@ const TRIGGERS = [
|
|||||||
type Props = { onError: (msg: string) => void };
|
type Props = { onError: (msg: string) => void };
|
||||||
|
|
||||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||||
|
const [highlightOn, setHighlightOn] = useState(false);
|
||||||
|
const [hlWorked, setHlWorked] = useState(false);
|
||||||
|
const [followMode, setFollowMode] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||||
|
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||||
|
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||||
|
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||||
|
}, []);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -229,10 +239,36 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||||||
{t('udpp.intro')}
|
this panel is where the WSJT-X link is configured. */}
|
||||||
</div>
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||||
|
<Checkbox checked={highlightOn}
|
||||||
|
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.highlight')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/* Nested under the switch above: the same feature, and meaningless
|
||||||
|
while that one is off. */}
|
||||||
|
{highlightOn && (
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||||||
|
<Checkbox checked={hlWorked}
|
||||||
|
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.hlWorked')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||||
|
<Checkbox checked={followMode}
|
||||||
|
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||||||
|
<span>
|
||||||
|
{t('udpp.followMode')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<Section
|
<Section
|
||||||
title={t('udpp.inboundTitle')}
|
title={t('udpp.inboundTitle')}
|
||||||
icon={<ArrowDownToLine className="size-4" />}
|
icon={<ArrowDownToLine className="size-4" />}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// The design is DXHunter's — the layout, the badges, the wording — repainted in
|
// The design is DXHunter's — the layout, the badges, the wording — repainted in
|
||||||
// the app's theme tokens rather than its hard-coded slate/pink.
|
// the app's theme tokens rather than its hard-coded slate/pink.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react';
|
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -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();
|
||||||
@@ -181,7 +106,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
try {
|
try {
|
||||||
await WatchlistAdd(c, addContest);
|
await WatchlistAdd(c, addContest);
|
||||||
setAddCall('');
|
setAddCall('');
|
||||||
flash(t(addContest ? 'wl.addedContest' : 'wl.added', { call: c }), false);
|
// The confirmation is the app-level notice (App.tsx, on watchlist:changed)
|
||||||
|
// — saying it twice, once per place a call can be added from, was noise.
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
} catch (e: any) { flash(String(e?.message ?? e), true); }
|
||||||
};
|
};
|
||||||
@@ -190,15 +116,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
// entry is cheap to undo (type it again), so it does not earn a confirmation
|
||||||
// the other two buttons do not have.
|
// the other two buttons do not have.
|
||||||
const remove = async (call: string) => {
|
const remove = async (call: string) => {
|
||||||
try { await WatchlistRemove(call); flash(t('wl.removed', { call }), false); await refresh(); }
|
try { await WatchlistRemove(call); await refresh(); }
|
||||||
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;
|
||||||
@@ -224,18 +146,20 @@ 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)' };
|
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
||||||
case 'new-slot': return { label: t('clg2.newSlot'), color: '#5AC8FA' };
|
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
|
||||||
default: return null;
|
// operator reads a cluster line with.
|
||||||
}
|
const fmtMHz = (hz: number) => {
|
||||||
|
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
|
||||||
|
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const chip = (color: string, text: string, extra?: string) => (
|
const chip = (color: string, text: string, extra?: string) => (
|
||||||
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)}
|
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
|
||||||
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
|
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
@@ -247,15 +171,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
|
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
|
||||||
{/* Header: the counters alone, centred — they are the tab's headline.
|
{/* Header: the counters alone, centred — they are the tab's headline.
|
||||||
Everything one INTERACTS with lives on the second row. */}
|
Everything one INTERACTS with lives on the second row. */}
|
||||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
|
||||||
<Eye className="size-4 text-primary shrink-0" />
|
<Eye className="size-4 text-primary shrink-0" />
|
||||||
<span>
|
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
|
||||||
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
|
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
|
||||||
<span className="mx-1.5 opacity-50">|</span>
|
<span className="opacity-40">|</span>
|
||||||
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
|
<span className="flex items-center gap-1.5">{t('wl.cActive')}
|
||||||
<span className="mx-1.5 opacity-50">|</span>
|
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
|
||||||
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
|
<span className="opacity-40">|</span>
|
||||||
</span>
|
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
|
||||||
|
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
|
||||||
|
? 'text-warning border-warning/40 bg-warning/10'
|
||||||
|
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
|
||||||
</div>
|
</div>
|
||||||
{/* toolbar */}
|
{/* toolbar */}
|
||||||
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
|
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
|
||||||
@@ -328,33 +255,37 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
||||||
return (
|
return (
|
||||||
<div key={e.callsign}
|
<div key={e.callsign}
|
||||||
className={cn('rounded-lg border bg-card p-3',
|
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||||
needed > 0 ? 'border-warning/50' : 'border-border',
|
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||||
e.isContest && 'border-l-4 border-l-warning')}>
|
e.isContest && 'border-l-4 border-l-warning')}>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-lg font-bold font-mono text-primary">{e.callsign}</span>
|
{/* Proportional, not monospaced: DXHunter sets this one in the
|
||||||
|
interface font and the difference is the first thing an
|
||||||
|
operator notices with the two windows side by side. There is
|
||||||
|
nothing to align here — it is a heading, not a column. */}
|
||||||
|
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||||
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
||||||
{e.isContest && (
|
{e.isContest && (
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||||
title={t('wl.contestHint')}>
|
title={t('wl.contestHint')}>
|
||||||
<Trophy className="size-3" /> {t('wl.contest')}
|
<Trophy className="size-3" /> {t('wl.contest')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
|
{e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
|
||||||
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
|
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
|
||||||
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||||
{e.clubLogLiveStream && (
|
{e.clubLogLiveStream && (
|
||||||
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
||||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||||
)}
|
)}
|
||||||
{list.length > 0 && (needed > 0
|
{list.length > 0 && (needed > 0
|
||||||
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
||||||
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
||||||
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
|
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
|
||||||
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
|
<span className="text-[11px] text-muted-foreground">• {e.lastSeenStr}</span>
|
||||||
)}
|
)}
|
||||||
{e.spotCount > 0 && (
|
{e.spotCount > 0 && (
|
||||||
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
|
<span className="text-[11px] text-muted-foreground/70">• {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button type="button" title={t('wl.toggleContest')}
|
<button type="button" title={t('wl.toggleContest')}
|
||||||
@@ -385,14 +316,21 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
|||||||
onClick={() => onSpotSelect?.(s)}
|
onClick={() => onSpotSelect?.(s)}
|
||||||
onDoubleClick={() => onSpotClick?.(s)}
|
onDoubleClick={() => onSpotClick?.(s)}
|
||||||
title={t('wl.spotTip')}
|
title={t('wl.spotTip')}
|
||||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/40 hover:bg-muted text-left',
|
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||||
!done && 'border-l-2 border-warning')}>
|
!done && 'border-l-2 border-warning')}>
|
||||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
{/* Worked or wanted, said with a symbol at the head of
|
||||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
the line as well as with the stripe down its side —
|
||||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
the same two marks DXHunter uses, and the one an eye
|
||||||
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
|
finds first when a card holds ten rows. */}
|
||||||
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
|
{done
|
||||||
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
? <Check className="size-4 shrink-0 text-success" />
|
||||||
|
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
|
||||||
|
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||||
|
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||||
|
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||||
|
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||||
|
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
|
||||||
{badge && chip(badge.color, badge.label)}
|
{badge && chip(badge.color, badge.label)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{done
|
{done
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
// Auto-call: let OpsLog answer a decode without the operator clicking it.
|
|
||||||
//
|
|
||||||
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
|
|
||||||
//
|
|
||||||
// Two programs from the same shack deciding on their own to answer the same
|
|
||||||
// decode is worse than either doing it alone: they cannot see each other, so
|
|
||||||
// they key over one another, and afterwards there is no telling which of them
|
|
||||||
// called. The duplicate is the one to remove, and DXHunter is where this lives.
|
|
||||||
//
|
|
||||||
// There is no switch in Preferences and no button on the decodes toolbar, and
|
|
||||||
// App.tsx returns before the loop can run. The file is kept whole: the rules
|
|
||||||
// below are the delicate part, argued over and tested, and rewriting them from
|
|
||||||
// memory later would be worse than leaving them here. Reinstating the feature
|
|
||||||
// means restoring all three — the settings page, the button, and the guard.
|
|
||||||
//
|
|
||||||
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
|
|
||||||
// as a series of refusals rather than a search for a reason to call. Everything
|
|
||||||
// below has to be true; anything unknown means no.
|
|
||||||
//
|
|
||||||
// The decision is made here, in one pure function, precisely because it is the
|
|
||||||
// dangerous part: it can be read, argued with and tested without a radio.
|
|
||||||
|
|
||||||
export type AutoCallCriteria = {
|
|
||||||
dxcc: boolean; // entity never worked
|
|
||||||
bandmode: boolean; // entity worked, but neither this band nor this mode
|
|
||||||
band: boolean; // entity never worked on this band
|
|
||||||
mode: boolean; // entity never worked in this mode
|
|
||||||
slot: boolean; // band and mode each worked, never together
|
|
||||||
grid: boolean; // square wanted under the grid scope
|
|
||||||
county: boolean; // US county never worked
|
|
||||||
pota: boolean; // park never worked
|
|
||||||
// No SOTA here, though the shape invites it: a decode carries no summit
|
|
||||||
// reference and the backend publishes no "new summit" flag, so a criterion
|
|
||||||
// for it could never be true. It WAS declared, translated and impossible to
|
|
||||||
// tick — a field that lies about what the feature can do.
|
|
||||||
pfx: boolean; // CQ WPX prefix never worked
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutoCallSettings = {
|
|
||||||
enabled: boolean;
|
|
||||||
criteria: AutoCallCriteria;
|
|
||||||
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
|
|
||||||
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
|
|
||||||
// the request, not "call it every time it appears".
|
|
||||||
watch: string[];
|
|
||||||
// Empty means call a watched callsign whenever it is not already worked.
|
|
||||||
watchCriteria: AutoCallCriteria;
|
|
||||||
// Seconds to ignore a callsign after calling it, so a station that keeps
|
|
||||||
// sending CQ is not re-answered every slot while the QSO is in progress.
|
|
||||||
cooldownSec: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyCriteria: AutoCallCriteria = {
|
|
||||||
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
|
|
||||||
grid: false, county: false, pota: false, pfx: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultAutoCall: AutoCallSettings = {
|
|
||||||
// OFF, and it stays off until asked for. Unattended transmit is not something
|
|
||||||
// to inherit from an upgrade.
|
|
||||||
enabled: false,
|
|
||||||
criteria: { ...emptyCriteria },
|
|
||||||
watch: [],
|
|
||||||
watchCriteria: { ...emptyCriteria },
|
|
||||||
cooldownSec: 120,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AC_KEY = 'opslog.autoCall';
|
|
||||||
|
|
||||||
export function loadAutoCall(): AutoCallSettings {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(AC_KEY);
|
|
||||||
if (!raw) return { ...defaultAutoCall };
|
|
||||||
const v = JSON.parse(raw);
|
|
||||||
const out: AutoCallSettings = {
|
|
||||||
...defaultAutoCall,
|
|
||||||
...v,
|
|
||||||
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
|
||||||
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
|
||||||
watch: Array.isArray(v?.watch) ? v.watch : [],
|
|
||||||
};
|
|
||||||
// DISARMED ON SIGHT, and written back disabled.
|
|
||||||
//
|
|
||||||
// The runtime guard in App.tsx stops this build from calling anyone, but it
|
|
||||||
// leaves "enabled": true sitting in storage, where any build without the
|
|
||||||
// guard — an older one an operator reinstalls, a machine that upgrades
|
|
||||||
// later — reads it and keys the transmitter for a feature with no switch
|
|
||||||
// left to turn off. A withdrawn feature that keys a radio has to be
|
|
||||||
// disarmed where it is REMEMBERED, not only where it runs.
|
|
||||||
if (out.enabled) {
|
|
||||||
out.enabled = false;
|
|
||||||
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch { return { ...defaultAutoCall }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const autoCallKey = AC_KEY;
|
|
||||||
|
|
||||||
// A decode's resolved novelty, the same shape the panel already renders from.
|
|
||||||
export type DecodeStatus = {
|
|
||||||
status?: string;
|
|
||||||
worked_call?: boolean;
|
|
||||||
new_grid?: boolean;
|
|
||||||
grid_state?: string;
|
|
||||||
new_county?: boolean;
|
|
||||||
new_pota?: boolean;
|
|
||||||
new_pfx?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
|
|
||||||
export function matchesWildcard(pattern: string, call: string): boolean {
|
|
||||||
const p = pattern.trim().toUpperCase();
|
|
||||||
const c = call.trim().toUpperCase();
|
|
||||||
if (!p) return false;
|
|
||||||
const re = new RegExp('^' + p.split('').map((ch) => (
|
|
||||||
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
)).join('') + '$');
|
|
||||||
return re.test(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// anyCriterion is false for an all-off set, which is what makes "watch this
|
|
||||||
// callsign, no conditions" expressible.
|
|
||||||
function anyCriterion(c: AutoCallCriteria): boolean {
|
|
||||||
return Object.values(c).some(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// meets reports whether a decode satisfies at least one ticked criterion.
|
|
||||||
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
|
|
||||||
if (c.dxcc && e.status === 'new') return true;
|
|
||||||
// Each status is exclusive, so a station that is new on both counts matches
|
|
||||||
// ONLY this criterion — ticking "new band" alone would not catch it, which is
|
|
||||||
// the wrong way round: it is the better catch of the two.
|
|
||||||
if (c.bandmode && e.status === 'new-band-mode') return true;
|
|
||||||
if (c.band && e.status === 'new-band') return true;
|
|
||||||
if (c.mode && e.status === 'new-mode') return true;
|
|
||||||
if (c.slot && e.status === 'new-slot') return true;
|
|
||||||
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
|
|
||||||
// and calling again would work a duplicate to chase a QSL.
|
|
||||||
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
|
|
||||||
if (c.county && e.new_county) return true;
|
|
||||||
if (c.pota && e.new_pota) return true;
|
|
||||||
if (c.pfx && e.new_pfx) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AutoCallDecode = {
|
|
||||||
call: string;
|
|
||||||
cq?: boolean;
|
|
||||||
msg?: string;
|
|
||||||
instance?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// shouldAutoCall decides whether to answer one decode. The reason is returned
|
|
||||||
// for the log: an automatic transmission with no record of WHY is the thing an
|
|
||||||
// operator cannot argue with after the fact.
|
|
||||||
export function shouldAutoCall(
|
|
||||||
s: AutoCallSettings,
|
|
||||||
d: AutoCallDecode,
|
|
||||||
e: DecodeStatus | undefined,
|
|
||||||
opts: {
|
|
||||||
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
|
|
||||||
//
|
|
||||||
// The distinction is the whole bug it fixes: with two instances the caller
|
|
||||||
// used to test a single global transmit flag, which belonged to whichever
|
|
||||||
// receiver reported last. So while slice A worked a station, slice B looked
|
|
||||||
// idle and auto-call started another QSO on it — and the moment either
|
|
||||||
// finished it chained straight into the next. One station at a time means
|
|
||||||
// one across ALL receivers, not one per receiver.
|
|
||||||
busy: boolean;
|
|
||||||
calledAt: Map<string, number>;
|
|
||||||
now: number;
|
|
||||||
myCall?: string;
|
|
||||||
},
|
|
||||||
): { call: boolean; reason: string } {
|
|
||||||
const no = (why: string) => ({ call: false, reason: why });
|
|
||||||
if (!s.enabled) return no('off');
|
|
||||||
if (!e) return no('status not resolved yet');
|
|
||||||
const call = (d.call ?? '').trim().toUpperCase();
|
|
||||||
if (!call) return no('no callsign');
|
|
||||||
// Never answer ourselves, however the decode reached us.
|
|
||||||
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
|
|
||||||
// NOT limited to a CQ, deliberately.
|
|
||||||
//
|
|
||||||
// It used to be, on the reasoning that answering a station mid-QSO is calling
|
|
||||||
// over somebody. That reasoning ignored the case the feature exists for: a
|
|
||||||
// DXpedition running a pileup never sends CQ at all — it works caller after
|
|
||||||
// caller — so the rule sat out the one contact auto-call was turned on for. A
|
|
||||||
// new entity on 15 m FT8, decode after decode, and not a single transmission.
|
|
||||||
//
|
|
||||||
// WHEN to transmit is not ours to decide either: the Reply goes to the
|
|
||||||
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
|
|
||||||
// behaviours, both belonging to the program that owns the timing. Here the
|
|
||||||
// question is only whether the station is one the operator wants — the
|
|
||||||
// criteria below answer that, and the cooldown and the busy check keep it from
|
|
||||||
// calling twice.
|
|
||||||
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
|
|
||||||
// has not finished with. Starting a second exchange before the first is done
|
|
||||||
// is what turned this into a machine that called without stopping.
|
|
||||||
if (opts.busy) return no('a QSO is already in progress');
|
|
||||||
const last = opts.calledAt.get(call);
|
|
||||||
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
|
|
||||||
|
|
||||||
// The watch list first: an explicitly named station outranks the general
|
|
||||||
// criteria, and may carry conditions of its own.
|
|
||||||
const watched = s.watch.some((p) => matchesWildcard(p, call));
|
|
||||||
if (watched) {
|
|
||||||
if (!anyCriterion(s.watchCriteria)) {
|
|
||||||
// No conditions attached: call it unless it is already worked.
|
|
||||||
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
|
|
||||||
}
|
|
||||||
return meets(s.watchCriteria, e)
|
|
||||||
? { call: true, reason: 'watch list + criteria' }
|
|
||||||
: no('watched, but no criterion met');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
|
|
||||||
return meets(s.criteria, e)
|
|
||||||
? { call: true, reason: 'criteria' }
|
|
||||||
: no('no criterion met');
|
|
||||||
}
|
|
||||||
@@ -111,3 +111,26 @@ export function bandRange(band: string): [number, number] | undefined {
|
|||||||
export function bandSegments(band: string): Seg[] {
|
export function bandSegments(band: string): Seg[] {
|
||||||
return plan().segments[band] ?? [];
|
return plan().segments[band] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' when it falls
|
||||||
|
// outside every known allocation.
|
||||||
|
//
|
||||||
|
// Lives here rather than in a panel because more than one place has to answer
|
||||||
|
// the same question the same way: the entry form retunes on a typed frequency,
|
||||||
|
// and the QSO editor has to keep band and frequency agreeing when one of them
|
||||||
|
// is corrected. Kept in step with BandFromHz on the Go side.
|
||||||
|
export function bandForMHz(mhz: number): string {
|
||||||
|
if (!mhz || isNaN(mhz)) return '';
|
||||||
|
const plan: [number, number, string][] = [
|
||||||
|
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
|
||||||
|
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
|
||||||
|
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
|
||||||
|
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
|
||||||
|
// Microwave, ADIF 3.1.7 ranges.
|
||||||
|
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
|
||||||
|
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
|
||||||
|
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
|
||||||
|
];
|
||||||
|
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// What a decoding program is CALLED, as against what it calls itself.
|
||||||
|
//
|
||||||
|
// Every WSJT-X-family packet carries an "id" naming the sending program, and
|
||||||
|
// OpsLog shows it wherever a receiver has to be told apart from another. Most
|
||||||
|
// of them send the name on the box: "WSJT-X", "JTDX", "MSHV".
|
||||||
|
//
|
||||||
|
// Nexus does not. It announces itself as "Tempo" — the name of the engine
|
||||||
|
// inside it — so an operator running Nexus saw a program on their screen they
|
||||||
|
// have never heard of, and had to work out that it was theirs.
|
||||||
|
//
|
||||||
|
// Only the LABEL is translated. The id stays the routing key everywhere else:
|
||||||
|
// a Reply, a Halt and the auto-call's own bookkeeping are matched against what
|
||||||
|
// the program sent, and renaming that would send them to nobody.
|
||||||
|
const NAMES: Record<string, string> = {
|
||||||
|
TEMPO: 'Nexus',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decoderName(id?: string): string {
|
||||||
|
const raw = (id ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
// Matched on the leading word: some programs append a version or an instance
|
||||||
|
// number ("WSJT-X - 2", "Tempo 1.4"), and the name is the part before it.
|
||||||
|
const head = raw.split(/[\s\-–—]+/)[0].toUpperCase();
|
||||||
|
return NAMES[head] ?? raw;
|
||||||
|
}
|
||||||
+137
-63
File diff suppressed because one or more lines are too long
@@ -172,6 +172,50 @@ export function greatCirclePoints(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitAtAntimeridian cuts a continuous (unwrapped) path into the pieces that
|
||||||
|
// fit on a map showing ONE world, each piece with longitudes back inside ±180.
|
||||||
|
//
|
||||||
|
// greatCirclePoints deliberately lets longitude run past ±180 so the polyline
|
||||||
|
// stays smooth. That is right for a map with repeating world copies, and wrong
|
||||||
|
// for one without: an arc from Australia to South America came out at 190°,
|
||||||
|
// 210°, 250° — drawn into the empty space off the right-hand edge, ending
|
||||||
|
// nowhere, while its own end marker sat correctly on the far left. From VK,
|
||||||
|
// where most paths cross the antimeridian, that was most of the map's arcs.
|
||||||
|
//
|
||||||
|
// Each crossing ends one piece at exactly ±180 and starts the next at the
|
||||||
|
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
||||||
|
// and re-enters the other at the height it left. Leaflet takes the result as a
|
||||||
|
// multi-polyline, so one path is still one layer.
|
||||||
|
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
||||||
|
if (pts.length === 0) return [];
|
||||||
|
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
||||||
|
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||||
|
const norm = (lon: number) => lon - 360 * world(lon);
|
||||||
|
const out: [number, number][][] = [];
|
||||||
|
let cur: [number, number][] = [];
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const [lat, lon] = pts[i];
|
||||||
|
if (i > 0) {
|
||||||
|
const [pLat, pLon] = pts[i - 1];
|
||||||
|
const wPrev = world(pLon), wCur = world(lon);
|
||||||
|
if (wPrev !== wCur) {
|
||||||
|
const east = wCur > wPrev;
|
||||||
|
// The meridian actually crossed, in unwrapped degrees.
|
||||||
|
const edge = 180 + 360 * Math.min(wPrev, wCur);
|
||||||
|
const f = (edge - pLon) / (lon - pLon);
|
||||||
|
const edgeLat = pLat + f * (lat - pLat);
|
||||||
|
cur.push([edgeLat, east ? 180 : -180]);
|
||||||
|
out.push(cur);
|
||||||
|
cur = [[edgeLat, east ? -180 : 180]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur.push([lat, norm(lon)]);
|
||||||
|
}
|
||||||
|
if (cur.length > 1) out.push(cur);
|
||||||
|
else if (cur.length === 1 && out.length === 0) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||||
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||||
|
|
||||||
|
|||||||
@@ -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); };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type SpotDisplayOptions = {
|
|||||||
// keep being computed; only the telling stops, so ticking the box back on
|
// keep being computed; only the telling stops, so ticking the box back on
|
||||||
// needs no rescan.
|
// needs no rescan.
|
||||||
chasePota: boolean; chaseSota: boolean;
|
chasePota: boolean; chaseSota: boolean;
|
||||||
|
chaseCounty: boolean; chasePfx: boolean; chaseGrid: boolean; chaseState: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// chasePota/chaseSota read the switches directly — for the places that show a
|
// chasePota/chaseSota read the switches directly — for the places that show a
|
||||||
@@ -30,6 +31,43 @@ export function chasePota(): boolean {
|
|||||||
export function chaseSota(): boolean {
|
export function chaseSota(): boolean {
|
||||||
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
||||||
}
|
}
|
||||||
|
export function chaseCounty(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseCounty') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chasePfx(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chasePfx') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseState(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseState') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
// chaseGrid mirrors the backend "chase new grids" setting (Settings writes the
|
||||||
|
// mirror on load and on toggle) so the display layer can gate NEW GRID without
|
||||||
|
// an async round-trip per row.
|
||||||
|
export function chaseGrid(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseGrids') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseAllows answers "does this operator chase this kind of thing at all?"
|
||||||
|
// for the ORTHOGONAL markers — park, square, prefix, county.
|
||||||
|
//
|
||||||
|
// The switches were written for the cluster and stayed there, so an operator
|
||||||
|
// who does not chase parks still met NEW POTA in the decode list and in Chase
|
||||||
|
// new: the same badge, withdrawn on one screen and shouting on the next. The
|
||||||
|
// setting is about what the operator hunts, not about which panel is open.
|
||||||
|
//
|
||||||
|
// The keys are both the status-entry field names (new_pota…) and the shorter
|
||||||
|
// category names the panels filter with (pota…), so one call serves both.
|
||||||
|
// Anything without a switch of its own — a new US state — is always allowed.
|
||||||
|
export function chaseAllows(key: string): boolean {
|
||||||
|
switch (key) {
|
||||||
|
case 'new_pota': case 'pota': return chasePota();
|
||||||
|
case 'new_grid': case 'grid': return chaseGrid();
|
||||||
|
case 'new_pfx': case 'pfx': return chasePfx();
|
||||||
|
case 'new_county': case 'cty': return chaseCounty();
|
||||||
|
case 'new_state': case 'state': return chaseState();
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Both options are withdrawn from the filter panel for now. The machinery below
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
@@ -44,16 +82,18 @@ export function readSpotDisplayOptions(): SpotDisplayOptions {
|
|||||||
// The EXPOSED flag only withdraws the two original switches; the chase
|
// The EXPOSED flag only withdraws the two original switches; the chase
|
||||||
// switches are live regardless.
|
// switches are live regardless.
|
||||||
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
||||||
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() };
|
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota(), chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(), chaseState: chaseState() };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
chasePota: chasePota(), chaseSota: chaseSota(),
|
chasePota: chasePota(), chaseSota: chaseSota(),
|
||||||
|
chaseCounty: chaseCounty(), chasePfx: chasePfx(), chaseGrid: chaseGrid(),
|
||||||
|
chaseState: chaseState(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true };
|
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true, chaseCounty: true, chasePfx: true, chaseGrid: true, chaseState: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +105,7 @@ type Entry = {
|
|||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
new_pfx?: boolean;
|
new_pfx?: boolean;
|
||||||
new_grid?: boolean;
|
new_grid?: boolean;
|
||||||
|
new_state?: boolean;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
// applySpotDisplay rewrites a status entry per the options, so every consumer —
|
||||||
@@ -98,6 +139,20 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
|||||||
if (!o.chasePota && e.new_pota) {
|
if (!o.chasePota && e.new_pota) {
|
||||||
e = { ...e, new_pota: false } as NonNullable<T>;
|
e = { ...e, new_pota: false } as NonNullable<T>;
|
||||||
}
|
}
|
||||||
|
// Same withdrawal for the other extra markers: the facts keep being
|
||||||
|
// computed, only the telling stops — re-ticking a box needs no rescan.
|
||||||
|
if (!o.chaseCounty && e.new_county) {
|
||||||
|
e = { ...e, new_county: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chasePfx && e.new_pfx) {
|
||||||
|
e = { ...e, new_pfx: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseGrid && e.new_grid) {
|
||||||
|
e = { ...e, new_grid: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
|
if (!o.chaseState && e.new_state) {
|
||||||
|
e = { ...e, new_state: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// grid magenta — the last hue in the categorical set that is not already
|
// grid magenta — the last hue in the categorical set that is not already
|
||||||
// spoken for here and does not read as a status; a grid is
|
// spoken for here and does not read as a status; a grid is
|
||||||
// never urgent the way a new entity is
|
// never urgent the way a new entity is
|
||||||
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
|
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
|
||||||
|
|
||||||
export type SpotMarker = {
|
export type SpotMarker = {
|
||||||
key: SpotMarkerKey;
|
key: SpotMarkerKey;
|
||||||
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
|
|||||||
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
|
||||||
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
|
||||||
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
|
||||||
|
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
|
||||||
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
||||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'dxhunter' | 'dxhunter-orange' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||||
// then darks, with high-contrast last — it is an accessibility choice, not a
|
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||||
// taste one, and listing it among the moods buries it.
|
// taste one, and listing it among the moods buries it.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
||||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dxhunter', 'dxhunter-orange',
|
||||||
'high-contrast',
|
'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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:[..]}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+162
-3
@@ -574,8 +574,8 @@
|
|||||||
"entity confirmed" cell would read as a button. */
|
"entity confirmed" cell would read as a button. */
|
||||||
--mx-call-conf: #22c55e;
|
--mx-call-conf: #22c55e;
|
||||||
--mx-call-work: #2c7a52;
|
--mx-call-work: #2c7a52;
|
||||||
--mx-dx-conf: #22d3ee;
|
--mx-dx-conf: #a78bfa;
|
||||||
--mx-dx-work: #1b6b7c;
|
--mx-dx-work: #5b4a9e;
|
||||||
--mx-none: #2c2f4d;
|
--mx-none: #2c2f4d;
|
||||||
|
|
||||||
--scrollbar-thumb: #383c63;
|
--scrollbar-thumb: #383c63;
|
||||||
@@ -862,6 +862,156 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 13: DXHunter — its own slate and blue -----------------------
|
||||||
|
Ported so an operator running both side by side does not switch between two
|
||||||
|
colour worlds every time they look up. It is Tailwind's slate scale, which
|
||||||
|
is what DXHunter is built on: page at slate-900, panels at slate-800, rules
|
||||||
|
at slate-700, and blue-500 for the accent — counted across its sources, not
|
||||||
|
guessed from one panel: blue is 132 uses to violet's 25, and the violet is
|
||||||
|
the PSK Reporter panel alone. Active tab, focus border, primary button: all
|
||||||
|
blue-500. The status colours are DXHunter's too — emerald for good, amber
|
||||||
|
for attention, cyan for information, red for trouble — so a green number
|
||||||
|
means the same thing in both windows. */
|
||||||
|
[data-theme="dxhunter"] {
|
||||||
|
--background: #0f172a; /* slate-900 — the page */
|
||||||
|
--foreground: #e2e8f0; /* slate-200 */
|
||||||
|
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||||
|
--card-foreground: #e2e8f0;
|
||||||
|
--popover: #1e293b;
|
||||||
|
--popover-foreground: #e2e8f0;
|
||||||
|
--primary: #3b82f6; /* blue-500 — active tabs, focus, buttons */
|
||||||
|
--primary-foreground: #f8fafc;
|
||||||
|
--secondary: #273449;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #1c2941; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||||
|
--accent: #2c3b54; /* hover / selection tint */
|
||||||
|
--accent-foreground: #cbd5e1;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fef2f2;
|
||||||
|
--destructive-muted: #3a1518;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||||
|
--input: #334155;
|
||||||
|
--ring: #60a5fa; /* blue-400 — its focus border */
|
||||||
|
|
||||||
|
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||||
|
--success-foreground: #04211a;
|
||||||
|
--success-muted: #0e3029;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #17564a;
|
||||||
|
|
||||||
|
--warning: #fbbf24; /* amber-400 */
|
||||||
|
--warning-foreground: #211803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #211e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #3a1621;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #5a2735;
|
||||||
|
|
||||||
|
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||||
|
--info-foreground: #04212a;
|
||||||
|
--info-muted: #0c2f3b;
|
||||||
|
--info-muted-foreground: #67e8f9;
|
||||||
|
--info-border: #155e6e;
|
||||||
|
|
||||||
|
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||||
|
as a button — and violet is where DXHunter puts its own second accent. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9e;
|
||||||
|
--mx-none: #334155;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||||
|
--scrollbar-thumb-hover: #475569;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 14: DXHunter orange — the same slate, OpsLog's own accent --
|
||||||
|
DXHunter's slate, kept exactly — page, panels, rules, muted text, and its
|
||||||
|
status colours — with OpsLog's orange in place of its blue. For an operator
|
||||||
|
who wants the two windows to sit together without OpsLog losing the accent
|
||||||
|
it is recognised by. The entity ramp goes VIOLET here for the same reason as
|
||||||
|
in the blue version: it must not read as the accent. */
|
||||||
|
[data-theme="dxhunter-orange"] {
|
||||||
|
--background: #0f172a; /* slate-900 — the page */
|
||||||
|
--foreground: #e2e8f0; /* slate-200 */
|
||||||
|
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||||
|
--card-foreground: #e2e8f0;
|
||||||
|
--popover: #1e293b;
|
||||||
|
--popover-foreground: #e2e8f0;
|
||||||
|
--primary: #f97316; /* orange-500 — OpsLog's own accent */
|
||||||
|
--primary-foreground: #1c0a02;
|
||||||
|
--secondary: #273449;
|
||||||
|
--secondary-foreground: #e2e8f0;
|
||||||
|
--muted: #1c2941; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||||
|
--accent: #2c3b54; /* hover / selection tint */
|
||||||
|
--accent-foreground: #cbd5e1;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #fef2f2;
|
||||||
|
--destructive-muted: #3a1518;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||||
|
--input: #334155;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||||
|
--success-foreground: #04211a;
|
||||||
|
--success-muted: #0e3029;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #17564a;
|
||||||
|
|
||||||
|
--warning: #fbbf24; /* amber-400 */
|
||||||
|
--warning-foreground: #211803;
|
||||||
|
--warning-muted: #33280f;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #4f3e15;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #211e04;
|
||||||
|
--caution-muted: #322d0e;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #4b4315;
|
||||||
|
|
||||||
|
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||||
|
--danger-foreground: #250912;
|
||||||
|
--danger-muted: #3a1621;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #5a2735;
|
||||||
|
|
||||||
|
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||||
|
--info-foreground: #04212a;
|
||||||
|
--info-muted: #0c2f3b;
|
||||||
|
--info-muted-foreground: #67e8f9;
|
||||||
|
--info-border: #155e6e;
|
||||||
|
|
||||||
|
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||||
|
as a button — and violet is where DXHunter puts its own second accent. */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2c7a52;
|
||||||
|
--mx-dx-conf: #a78bfa;
|
||||||
|
--mx-dx-work: #5b4a9e;
|
||||||
|
--mx-none: #334155;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||||
|
--scrollbar-thumb-hover: #475569;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
@@ -905,7 +1055,9 @@
|
|||||||
[data-theme="high-contrast"],
|
[data-theme="high-contrast"],
|
||||||
[data-theme="dark-indigo"],
|
[data-theme="dark-indigo"],
|
||||||
[data-theme="dark-teal"],
|
[data-theme="dark-teal"],
|
||||||
[data-theme="dark-plum"] {
|
[data-theme="dark-plum"],
|
||||||
|
[data-theme="dxhunter"],
|
||||||
|
[data-theme="dxhunter-orange"] {
|
||||||
--chart-1: #3987e5;
|
--chart-1: #3987e5;
|
||||||
--chart-2: #199e70;
|
--chart-2: #199e70;
|
||||||
--chart-3: #c98500;
|
--chart-3: #c98500;
|
||||||
@@ -1086,3 +1238,10 @@
|
|||||||
.overflow-scroll {
|
.overflow-scroll {
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The ground beside the planet, on every map: Leaflet paints its container a
|
||||||
|
hard-coded light grey (#ddd), which reads as a broken tile against any
|
||||||
|
theme. The surround follows the theme's own card surface instead. */
|
||||||
|
.leaflet-container {
|
||||||
|
background: var(--card) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.1';
|
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';
|
||||||
|
|||||||
Vendored
+70
@@ -12,8 +12,10 @@ import {award} from '../models';
|
|||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {bandopen} from '../models';
|
import {bandopen} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
|
import {dxped} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {pskrtgt} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
import {psu} from '../models';
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
@@ -142,6 +144,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
|
|||||||
|
|
||||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||||
|
|
||||||
|
export function ConfigureDecoderMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function ConnectAllClusters():Promise<void>;
|
export function ConnectAllClusters():Promise<void>;
|
||||||
|
|
||||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||||
@@ -158,6 +162,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DVKCancelRecord():Promise<void>;
|
export function DVKCancelRecord():Promise<void>;
|
||||||
|
|
||||||
|
export function DVKDelete(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DVKPlay(arg1:number):Promise<void>;
|
export function DVKPlay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DVKPreview(arg1:number):Promise<void>;
|
export function DVKPreview(arg1:number):Promise<void>;
|
||||||
@@ -410,6 +416,10 @@ export function GetAudioMonitorPref():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
|
export function GetAutoCallSettings():Promise<main.AutoCallSettings>;
|
||||||
|
|
||||||
|
export function GetAutoCallStatus():Promise<main.AutoCallStatus>;
|
||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
|
|
||||||
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
|
||||||
@@ -444,10 +454,14 @@ 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>>;
|
||||||
|
|
||||||
|
export function GetChaseSettings():Promise<main.ChaseSettings>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -470,6 +484,10 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
|
|||||||
|
|
||||||
export function GetDVKStatus():Promise<main.DVKStatus>;
|
export function GetDVKStatus():Promise<main.DVKStatus>;
|
||||||
|
|
||||||
|
export function GetDXWorldNews():Promise<Array<dxped.News>>;
|
||||||
|
|
||||||
|
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
|
||||||
|
|
||||||
export function GetDataDir():Promise<string>;
|
export function GetDataDir():Promise<string>;
|
||||||
|
|
||||||
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
|
||||||
@@ -538,8 +556,12 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPSKAnalysis():Promise<pskrtgt.Analysis>;
|
||||||
|
|
||||||
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||||
|
|
||||||
|
export function GetPSKTargetSettings():Promise<main.PSKTargetSettings>;
|
||||||
|
|
||||||
export function GetPSUSettings():Promise<main.PSUSettings>;
|
export function GetPSUSettings():Promise<main.PSUSettings>;
|
||||||
|
|
||||||
export function GetPSUStatus():Promise<psu.Status>;
|
export function GetPSUStatus():Promise<psu.Status>;
|
||||||
@@ -606,6 +628,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
|||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWatchlistContestCalls():Promise<string>;
|
||||||
|
|
||||||
export function GetWatchlistContestPattern():Promise<string>;
|
export function GetWatchlistContestPattern():Promise<string>;
|
||||||
|
|
||||||
export function GetWebPublishConfig():Promise<webpub.Config>;
|
export function GetWebPublishConfig():Promise<webpub.Config>;
|
||||||
@@ -620,18 +644,28 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
|||||||
|
|
||||||
export function GetWorkedCallVariants():Promise<boolean>;
|
export function GetWorkedCallVariants():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtFollowMode():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtHighlight():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetWsjtHighlightWorked():Promise<boolean>;
|
||||||
|
|
||||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||||
|
|
||||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
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>;
|
||||||
|
|
||||||
export function IcomConsolePTT(arg1:boolean):Promise<void>;
|
export function IcomConsolePTT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomRecallBand(arg1:string,arg2:number):Promise<number>;
|
||||||
|
|
||||||
export function IcomRefresh():Promise<void>;
|
export function IcomRefresh():Promise<void>;
|
||||||
|
|
||||||
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
export function IcomScopeData():Promise<cat.ScopeSweep>;
|
||||||
@@ -934,6 +968,8 @@ export function RecomputeAwardRefsForCode(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshDXpeditions():Promise<void>;
|
||||||
|
|
||||||
export function RefreshKenwood():Promise<void>;
|
export function RefreshKenwood():Promise<void>;
|
||||||
|
|
||||||
export function RefreshSolar():Promise<void>;
|
export function RefreshSolar():Promise<void>;
|
||||||
@@ -960,6 +996,8 @@ export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<
|
|||||||
|
|
||||||
export function RescanAwards():Promise<void>;
|
export function RescanAwards():Promise<void>;
|
||||||
|
|
||||||
|
export function ResetAutoCall():Promise<void>;
|
||||||
|
|
||||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
@@ -1006,6 +1044,8 @@ export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>
|
|||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveAutoCallSettings(arg1:main.AutoCallSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
|
|
||||||
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
|
||||||
@@ -1020,6 +1060,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveCabrilloFile():Promise<string>;
|
export function SaveCabrilloFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||||
|
|
||||||
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
||||||
@@ -1054,6 +1096,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SavePSKTargetSettings(arg1:main.PSKTargetSettings):Promise<void>;
|
||||||
|
|
||||||
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
@@ -1114,6 +1158,12 @@ export function SetActiveRotor(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetAutoCall(arg1:boolean):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>;
|
||||||
@@ -1124,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>;
|
||||||
@@ -1186,10 +1238,14 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPSKTarget(arg1:string,arg2:string,arg3:number):Promise<void>;
|
||||||
|
|
||||||
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetScpClublogEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetSpotMax(arg1:number):Promise<void>;
|
export function SetSpotMax(arg1:number):Promise<void>;
|
||||||
@@ -1240,12 +1296,20 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWatchlistContestCalls(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
|
export function SetWatchlistContestPattern(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetWsjtHighlightWorked(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||||
@@ -1306,6 +1370,8 @@ export function TCIStopCW():Promise<void>;
|
|||||||
|
|
||||||
export function TailLogFile(arg1:number):Promise<string>;
|
export function TailLogFile(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function TakeAutoCallTarget(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function TestCloudlogUpload():Promise<string>;
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
@@ -1316,6 +1382,8 @@ export function TestEmail(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function TestHRDLogUpload():Promise<string>;
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestHamQTHUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
@@ -1370,6 +1438,8 @@ export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UploadCallsign(arg1:string):Promise<string>;
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH():Promise<void>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
export function WatchlistAdd(arg1:string,arg2:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ConfigureDecoderMode(arg1) {
|
||||||
|
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectAllClusters() {
|
export function ConnectAllClusters() {
|
||||||
return window['go']['main']['App']['ConnectAllClusters']();
|
return window['go']['main']['App']['ConnectAllClusters']();
|
||||||
}
|
}
|
||||||
@@ -254,6 +258,10 @@ export function DVKCancelRecord() {
|
|||||||
return window['go']['main']['App']['DVKCancelRecord']();
|
return window['go']['main']['App']['DVKCancelRecord']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DVKDelete(arg1) {
|
||||||
|
return window['go']['main']['App']['DVKDelete'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DVKPlay(arg1) {
|
export function DVKPlay(arg1) {
|
||||||
return window['go']['main']['App']['DVKPlay'](arg1);
|
return window['go']['main']['App']['DVKPlay'](arg1);
|
||||||
}
|
}
|
||||||
@@ -758,6 +766,14 @@ export function GetAudioSettings() {
|
|||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAutoCallSettings() {
|
||||||
|
return window['go']['main']['App']['GetAutoCallSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAutoCallStatus() {
|
||||||
|
return window['go']['main']['App']['GetAutoCallStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAutostartPrograms() {
|
export function GetAutostartPrograms() {
|
||||||
return window['go']['main']['App']['GetAutostartPrograms']();
|
return window['go']['main']['App']['GetAutostartPrograms']();
|
||||||
}
|
}
|
||||||
@@ -826,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']();
|
||||||
}
|
}
|
||||||
@@ -834,6 +854,10 @@ export function GetChaseNewSpots() {
|
|||||||
return window['go']['main']['App']['GetChaseNewSpots']();
|
return window['go']['main']['App']['GetChaseNewSpots']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChaseSettings() {
|
||||||
|
return window['go']['main']['App']['GetChaseSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -878,6 +902,14 @@ export function GetDVKStatus() {
|
|||||||
return window['go']['main']['App']['GetDVKStatus']();
|
return window['go']['main']['App']['GetDVKStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDXWorldNews() {
|
||||||
|
return window['go']['main']['App']['GetDXWorldNews']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDXpeditions() {
|
||||||
|
return window['go']['main']['App']['GetDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetDataDir() {
|
export function GetDataDir() {
|
||||||
return window['go']['main']['App']['GetDataDir']();
|
return window['go']['main']['App']['GetDataDir']();
|
||||||
}
|
}
|
||||||
@@ -1014,10 +1046,18 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSKAnalysis() {
|
||||||
|
return window['go']['main']['App']['GetPSKAnalysis']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPSKReporterStatus() {
|
export function GetPSKReporterStatus() {
|
||||||
return window['go']['main']['App']['GetPSKReporterStatus']();
|
return window['go']['main']['App']['GetPSKReporterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSKTargetSettings() {
|
||||||
|
return window['go']['main']['App']['GetPSKTargetSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPSUSettings() {
|
export function GetPSUSettings() {
|
||||||
return window['go']['main']['App']['GetPSUSettings']();
|
return window['go']['main']['App']['GetPSUSettings']();
|
||||||
}
|
}
|
||||||
@@ -1150,6 +1190,10 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWatchlistContestCalls() {
|
||||||
|
return window['go']['main']['App']['GetWatchlistContestCalls']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWatchlistContestPattern() {
|
export function GetWatchlistContestPattern() {
|
||||||
return window['go']['main']['App']['GetWatchlistContestPattern']();
|
return window['go']['main']['App']['GetWatchlistContestPattern']();
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1222,18 @@ export function GetWorkedCallVariants() {
|
|||||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWsjtFollowMode() {
|
||||||
|
return window['go']['main']['App']['GetWsjtFollowMode']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWsjtHighlight() {
|
||||||
|
return window['go']['main']['App']['GetWsjtHighlight']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetWsjtHighlightWorked() {
|
||||||
|
return window['go']['main']['App']['GetWsjtHighlightWorked']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetYaesuBandAntennas() {
|
export function GetYaesuBandAntennas() {
|
||||||
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
||||||
}
|
}
|
||||||
@@ -1190,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);
|
||||||
}
|
}
|
||||||
@@ -1202,6 +1262,10 @@ export function IcomConsolePTT(arg1) {
|
|||||||
return window['go']['main']['App']['IcomConsolePTT'](arg1);
|
return window['go']['main']['App']['IcomConsolePTT'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomRecallBand(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['IcomRecallBand'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomRefresh() {
|
export function IcomRefresh() {
|
||||||
return window['go']['main']['App']['IcomRefresh']();
|
return window['go']['main']['App']['IcomRefresh']();
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1870,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshDXpeditions() {
|
||||||
|
return window['go']['main']['App']['RefreshDXpeditions']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RefreshKenwood() {
|
export function RefreshKenwood() {
|
||||||
return window['go']['main']['App']['RefreshKenwood']();
|
return window['go']['main']['App']['RefreshKenwood']();
|
||||||
}
|
}
|
||||||
@@ -1858,6 +1926,10 @@ export function RescanAwards() {
|
|||||||
return window['go']['main']['App']['RescanAwards']();
|
return window['go']['main']['App']['RescanAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResetAutoCall() {
|
||||||
|
return window['go']['main']['App']['ResetAutoCall']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ResetAwardDefs() {
|
export function ResetAwardDefs() {
|
||||||
return window['go']['main']['App']['ResetAwardDefs']();
|
return window['go']['main']['App']['ResetAwardDefs']();
|
||||||
}
|
}
|
||||||
@@ -1950,6 +2022,10 @@ export function SaveAudioSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAutoCallSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAutoCallSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1) {
|
export function SaveAutostartPrograms(arg1) {
|
||||||
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
return window['go']['main']['App']['SaveAutostartPrograms'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1978,6 +2054,10 @@ export function SaveCabrilloFile() {
|
|||||||
return window['go']['main']['App']['SaveCabrilloFile']();
|
return window['go']['main']['App']['SaveCabrilloFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveChaseSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveChaseSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveClusterServer(arg1) {
|
export function SaveClusterServer(arg1) {
|
||||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2046,6 +2126,10 @@ export function SavePOTAToken(arg1) {
|
|||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePSKTargetSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePSKTargetSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SavePSUSettings(arg1) {
|
export function SavePSUSettings(arg1) {
|
||||||
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2250,18 @@ export function SetAlertEmailTo(arg1) {
|
|||||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetAutoCall(arg1) {
|
||||||
|
return window['go']['main']['App']['SetAutoCall'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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);
|
||||||
}
|
}
|
||||||
@@ -2186,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);
|
||||||
}
|
}
|
||||||
@@ -2310,6 +2410,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPSKTarget(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['SetPSKTarget'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPSUOutput(arg1) {
|
export function SetPSUOutput(arg1) {
|
||||||
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2318,6 +2422,10 @@ export function SetPassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetScpClublogEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetScpClublogEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetScpEnabled(arg1) {
|
export function SetScpEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2418,6 +2526,10 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWatchlistContestCalls(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWatchlistContestCalls'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetWatchlistContestPattern(arg1) {
|
export function SetWatchlistContestPattern(arg1) {
|
||||||
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
|
return window['go']['main']['App']['SetWatchlistContestPattern'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2430,6 +2542,18 @@ export function SetWorkedCallVariants(arg1) {
|
|||||||
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetWsjtFollowMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetWsjtHighlight(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetWsjtHighlightWorked(arg1) {
|
||||||
|
return window['go']['main']['App']['SetWsjtHighlightWorked'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetYaesuAFGain(arg1) {
|
export function SetYaesuAFGain(arg1) {
|
||||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2550,6 +2674,10 @@ export function TailLogFile(arg1) {
|
|||||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TakeAutoCallTarget(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['TakeAutoCallTarget'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestCloudlogUpload() {
|
export function TestCloudlogUpload() {
|
||||||
return window['go']['main']['App']['TestCloudlogUpload']();
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
}
|
}
|
||||||
@@ -2570,6 +2698,10 @@ export function TestHRDLogUpload() {
|
|||||||
return window['go']['main']['App']['TestHRDLogUpload']();
|
return window['go']['main']['App']['TestHRDLogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestHamQTHUpload() {
|
||||||
|
return window['go']['main']['App']['TestHamQTHUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -2678,6 +2810,10 @@ export function UploadCallsign(arg1) {
|
|||||||
return window['go']['main']['App']['UploadCallsign'](arg1);
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadFullLogHamQTH() {
|
||||||
|
return window['go']['main']['App']['UploadFullLogHamQTH']();
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1083,6 +1083,7 @@ export namespace cat {
|
|||||||
s_meter: number;
|
s_meter: number;
|
||||||
s_meter_raw: number;
|
s_meter_raw: number;
|
||||||
power_meter: number;
|
power_meter: number;
|
||||||
|
power_w: number;
|
||||||
swr: number;
|
swr: number;
|
||||||
swr_raw: number;
|
swr_raw: number;
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
@@ -1120,6 +1121,7 @@ export namespace cat {
|
|||||||
this.s_meter = source["s_meter"];
|
this.s_meter = source["s_meter"];
|
||||||
this.s_meter_raw = source["s_meter_raw"];
|
this.s_meter_raw = source["s_meter_raw"];
|
||||||
this.power_meter = source["power_meter"];
|
this.power_meter = source["power_meter"];
|
||||||
|
this.power_w = source["power_w"];
|
||||||
this.swr = source["swr"];
|
this.swr = source["swr"];
|
||||||
this.swr_raw = source["swr_raw"];
|
this.swr_raw = source["swr_raw"];
|
||||||
this.rf_power = source["rf_power"];
|
this.rf_power = source["rf_power"];
|
||||||
@@ -1464,6 +1466,37 @@ export namespace contest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace dxped {
|
||||||
|
|
||||||
|
export class News {
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
pub_date: string;
|
||||||
|
excerpt: string;
|
||||||
|
creator: string;
|
||||||
|
image_url: string;
|
||||||
|
tag: string;
|
||||||
|
calls: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new News(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.pub_date = source["pub_date"];
|
||||||
|
this.excerpt = source["excerpt"];
|
||||||
|
this.creator = source["creator"];
|
||||||
|
this.image_url = source["image_url"];
|
||||||
|
this.tag = source["tag"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace extsvc {
|
export namespace extsvc {
|
||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
@@ -1520,6 +1553,7 @@ export namespace extsvc {
|
|||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
cloudlog: ServiceConfig;
|
cloudlog: ServiceConfig;
|
||||||
hamlog: ServiceConfig;
|
hamlog: ServiceConfig;
|
||||||
|
hamqth: ServiceConfig;
|
||||||
delete_remote: boolean;
|
delete_remote: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -1535,6 +1569,7 @@ export namespace extsvc {
|
|||||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||||
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
this.hamlog = this.convertValues(source["hamlog"], ServiceConfig);
|
||||||
|
this.hamqth = this.convertValues(source["hamqth"], ServiceConfig);
|
||||||
this.delete_remote = source["delete_remote"];
|
this.delete_remote = source["delete_remote"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1889,6 +1924,7 @@ export namespace main {
|
|||||||
preroll_seconds: number;
|
preroll_seconds: number;
|
||||||
ptt_method: string;
|
ptt_method: string;
|
||||||
ptt_port: string;
|
ptt_port: string;
|
||||||
|
ptt_data: boolean;
|
||||||
format: string;
|
format: string;
|
||||||
from_gain: number;
|
from_gain: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
@@ -1910,6 +1946,7 @@ export namespace main {
|
|||||||
this.preroll_seconds = source["preroll_seconds"];
|
this.preroll_seconds = source["preroll_seconds"];
|
||||||
this.ptt_method = source["ptt_method"];
|
this.ptt_method = source["ptt_method"];
|
||||||
this.ptt_port = source["ptt_port"];
|
this.ptt_port = source["ptt_port"];
|
||||||
|
this.ptt_data = source["ptt_data"];
|
||||||
this.format = source["format"];
|
this.format = source["format"];
|
||||||
this.from_gain = source["from_gain"];
|
this.from_gain = source["from_gain"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
@@ -1917,6 +1954,66 @@ export namespace main {
|
|||||||
this.qso_play_gain = source["qso_play_gain"];
|
this.qso_play_gain = source["qso_play_gain"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class AutoCallSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
only: string;
|
||||||
|
attempts: number;
|
||||||
|
watched_attempts: number;
|
||||||
|
misses: number;
|
||||||
|
max_rounds: number;
|
||||||
|
rest_min: number;
|
||||||
|
on_screen_only: boolean;
|
||||||
|
trace: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoCallSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.only = source["only"];
|
||||||
|
this.attempts = source["attempts"];
|
||||||
|
this.watched_attempts = source["watched_attempts"];
|
||||||
|
this.misses = source["misses"];
|
||||||
|
this.max_rounds = source["max_rounds"];
|
||||||
|
this.rest_min = source["rest_min"];
|
||||||
|
this.on_screen_only = source["on_screen_only"];
|
||||||
|
this.trace = source["trace"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AutoCallStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
only: string;
|
||||||
|
target: string;
|
||||||
|
waiting: string;
|
||||||
|
calls: number;
|
||||||
|
max: number;
|
||||||
|
misses: number;
|
||||||
|
max_miss: number;
|
||||||
|
stopped: boolean;
|
||||||
|
greylisted: number;
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AutoCallStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.only = source["only"];
|
||||||
|
this.target = source["target"];
|
||||||
|
this.waiting = source["waiting"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.max = source["max"];
|
||||||
|
this.misses = source["misses"];
|
||||||
|
this.max_miss = source["max_miss"];
|
||||||
|
this.stopped = source["stopped"];
|
||||||
|
this.greylisted = source["greylisted"];
|
||||||
|
this.reason = source["reason"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AutostartLaunchResult {
|
export class AutostartLaunchResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2263,6 +2360,7 @@ export namespace main {
|
|||||||
backend: string;
|
backend: string;
|
||||||
omnirig_rig: number;
|
omnirig_rig: number;
|
||||||
omnirig_vfo: string;
|
omnirig_vfo: string;
|
||||||
|
digi_as_usb: boolean;
|
||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
@@ -2315,6 +2413,7 @@ export namespace main {
|
|||||||
this.backend = source["backend"];
|
this.backend = source["backend"];
|
||||||
this.omnirig_rig = source["omnirig_rig"];
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
this.omnirig_vfo = source["omnirig_vfo"];
|
this.omnirig_vfo = source["omnirig_vfo"];
|
||||||
|
this.digi_as_usb = source["digi_as_usb"];
|
||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
@@ -2428,6 +2527,20 @@ export namespace main {
|
|||||||
this.at = source["at"];
|
this.at = source["at"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChaseSettings {
|
||||||
|
mode: string;
|
||||||
|
sources: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChaseSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.sources = source["sources"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2656,6 +2769,44 @@ export namespace main {
|
|||||||
this.rec_slot = source["rec_slot"];
|
this.rec_slot = source["rec_slot"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class DXpedition {
|
||||||
|
dxcc: string;
|
||||||
|
callsign: string;
|
||||||
|
calls: string[];
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
bands: string[];
|
||||||
|
modes: string[];
|
||||||
|
qsl: string;
|
||||||
|
operators: string;
|
||||||
|
source: string;
|
||||||
|
link: string;
|
||||||
|
status: string;
|
||||||
|
status_chase: string;
|
||||||
|
unconfirmed: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new DXpedition(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.calls = source["calls"];
|
||||||
|
this.start_date = source["start_date"];
|
||||||
|
this.end_date = source["end_date"];
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.qsl = source["qsl"];
|
||||||
|
this.operators = source["operators"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.link = source["link"];
|
||||||
|
this.status = source["status"];
|
||||||
|
this.status_chase = source["status_chase"];
|
||||||
|
this.unconfirmed = source["unconfirmed"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DatabaseSettings {
|
export class DatabaseSettings {
|
||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
@@ -3226,6 +3377,20 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PSKTargetSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
scope: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PSKTargetSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.scope = source["scope"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class PSUSettings {
|
export class PSUSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
@@ -3278,11 +3443,13 @@ export namespace main {
|
|||||||
eqsl_sent: string;
|
eqsl_sent: string;
|
||||||
eqsl_rcvd: string;
|
eqsl_rcvd: string;
|
||||||
clublog_status: string;
|
clublog_status: string;
|
||||||
|
clublog_confirmed: string;
|
||||||
hrdlog_status: string;
|
hrdlog_status: string;
|
||||||
qrzcom_status: string;
|
qrzcom_status: string;
|
||||||
qrzcom_confirmed: string;
|
qrzcom_confirmed: string;
|
||||||
hamlog_status: string;
|
hamlog_status: string;
|
||||||
hamlog_confirmed: string;
|
hamlog_confirmed: string;
|
||||||
|
hamqth_status: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new QSLDefaults(source);
|
return new QSLDefaults(source);
|
||||||
@@ -3297,11 +3464,13 @@ export namespace main {
|
|||||||
this.eqsl_sent = source["eqsl_sent"];
|
this.eqsl_sent = source["eqsl_sent"];
|
||||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||||
this.clublog_status = source["clublog_status"];
|
this.clublog_status = source["clublog_status"];
|
||||||
|
this.clublog_confirmed = source["clublog_confirmed"];
|
||||||
this.hrdlog_status = source["hrdlog_status"];
|
this.hrdlog_status = source["hrdlog_status"];
|
||||||
this.qrzcom_status = source["qrzcom_status"];
|
this.qrzcom_status = source["qrzcom_status"];
|
||||||
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
this.qrzcom_confirmed = source["qrzcom_confirmed"];
|
||||||
this.hamlog_status = source["hamlog_status"];
|
this.hamlog_status = source["hamlog_status"];
|
||||||
this.hamlog_confirmed = source["hamlog_confirmed"];
|
this.hamlog_confirmed = source["hamlog_confirmed"];
|
||||||
|
this.hamqth_status = source["hamqth_status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class QSLEmailTemplates {
|
export class QSLEmailTemplates {
|
||||||
@@ -3836,6 +4005,7 @@ export namespace main {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
|
clublog: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ScpStatus(source);
|
return new ScpStatus(source);
|
||||||
@@ -3846,6 +4016,7 @@ export namespace main {
|
|||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
this.count = source["count"];
|
this.count = source["count"];
|
||||||
this.updated = source["updated"];
|
this.updated = source["updated"];
|
||||||
|
this.clublog = source["clublog"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -3955,6 +4126,11 @@ export namespace main {
|
|||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
county?: string;
|
county?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
new_state: boolean;
|
||||||
|
unconf_status?: boolean;
|
||||||
|
unconf_pfx?: boolean;
|
||||||
|
unconf_cty?: boolean;
|
||||||
|
unconf_state?: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
grid?: string;
|
grid?: string;
|
||||||
new_grid: boolean;
|
new_grid: boolean;
|
||||||
@@ -3981,6 +4157,11 @@ export namespace main {
|
|||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.county = source["county"];
|
this.county = source["county"];
|
||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
|
this.new_state = source["new_state"];
|
||||||
|
this.unconf_status = source["unconf_status"];
|
||||||
|
this.unconf_pfx = source["unconf_pfx"];
|
||||||
|
this.unconf_cty = source["unconf_cty"];
|
||||||
|
this.unconf_state = source["unconf_state"];
|
||||||
this.new_pota = source["new_pota"];
|
this.new_pota = source["new_pota"];
|
||||||
this.grid = source["grid"];
|
this.grid = source["grid"];
|
||||||
this.new_grid = source["new_grid"];
|
this.new_grid = source["new_grid"];
|
||||||
@@ -4789,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);
|
||||||
@@ -4802,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 {
|
||||||
@@ -4825,6 +5010,132 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace pskrtgt {
|
||||||
|
|
||||||
|
export class Bin {
|
||||||
|
offset_hz: number;
|
||||||
|
count: number;
|
||||||
|
avg_snr: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bin(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.offset_hz = source["offset_hz"];
|
||||||
|
this.count = source["count"];
|
||||||
|
this.avg_snr = source["avg_snr"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Entry {
|
||||||
|
call: string;
|
||||||
|
grid: string;
|
||||||
|
snr: number;
|
||||||
|
offset_hz: number;
|
||||||
|
age_sec: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Entry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.call = source["call"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.snr = source["snr"];
|
||||||
|
this.offset_hz = source["offset_hz"];
|
||||||
|
this.age_sec = source["age_sec"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Analysis {
|
||||||
|
target: string;
|
||||||
|
mode?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
online: boolean;
|
||||||
|
spots: number;
|
||||||
|
he_me: boolean;
|
||||||
|
he_me_seconds: number;
|
||||||
|
he_me_snr: number;
|
||||||
|
he_me_offset_hz: number;
|
||||||
|
target_uploads: boolean;
|
||||||
|
target_grid?: string;
|
||||||
|
near_him_count: number;
|
||||||
|
near_him_top: Entry[];
|
||||||
|
from_my_area_count: number;
|
||||||
|
from_my_area_top: Entry[];
|
||||||
|
path_open: boolean;
|
||||||
|
heard_by_count: number;
|
||||||
|
heard_near_me: number;
|
||||||
|
heard_near_me_top: Entry[];
|
||||||
|
decoded_by_count: number;
|
||||||
|
decoded_by_top: Entry[];
|
||||||
|
decoded_by_calls: string[];
|
||||||
|
pileup_count: number;
|
||||||
|
dial_hz: number;
|
||||||
|
ceiling_hz: number;
|
||||||
|
decodes_in_window: number;
|
||||||
|
bins: Bin[];
|
||||||
|
suggested_offset: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Analysis(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.target = source["target"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.online = source["online"];
|
||||||
|
this.spots = source["spots"];
|
||||||
|
this.he_me = source["he_me"];
|
||||||
|
this.he_me_seconds = source["he_me_seconds"];
|
||||||
|
this.he_me_snr = source["he_me_snr"];
|
||||||
|
this.he_me_offset_hz = source["he_me_offset_hz"];
|
||||||
|
this.target_uploads = source["target_uploads"];
|
||||||
|
this.target_grid = source["target_grid"];
|
||||||
|
this.near_him_count = source["near_him_count"];
|
||||||
|
this.near_him_top = this.convertValues(source["near_him_top"], Entry);
|
||||||
|
this.from_my_area_count = source["from_my_area_count"];
|
||||||
|
this.from_my_area_top = this.convertValues(source["from_my_area_top"], Entry);
|
||||||
|
this.path_open = source["path_open"];
|
||||||
|
this.heard_by_count = source["heard_by_count"];
|
||||||
|
this.heard_near_me = source["heard_near_me"];
|
||||||
|
this.heard_near_me_top = this.convertValues(source["heard_near_me_top"], Entry);
|
||||||
|
this.decoded_by_count = source["decoded_by_count"];
|
||||||
|
this.decoded_by_top = this.convertValues(source["decoded_by_top"], Entry);
|
||||||
|
this.decoded_by_calls = source["decoded_by_calls"];
|
||||||
|
this.pileup_count = source["pileup_count"];
|
||||||
|
this.dial_hz = source["dial_hz"];
|
||||||
|
this.ceiling_hz = source["ceiling_hz"];
|
||||||
|
this.decodes_in_window = source["decodes_in_window"];
|
||||||
|
this.bins = this.convertValues(source["bins"], Bin);
|
||||||
|
this.suggested_offset = source["suggested_offset"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace psu {
|
export namespace psu {
|
||||||
|
|
||||||
export class Status {
|
export class Status {
|
||||||
@@ -5243,6 +5554,8 @@ export namespace qso {
|
|||||||
qrzcom_qso_upload_status?: string;
|
qrzcom_qso_upload_status?: string;
|
||||||
qrzcom_qso_download_date?: string;
|
qrzcom_qso_download_date?: string;
|
||||||
qrzcom_qso_download_status?: string;
|
qrzcom_qso_download_status?: string;
|
||||||
|
clublog_qso_download_date?: string;
|
||||||
|
clublog_qso_download_status?: string;
|
||||||
contest_id?: string;
|
contest_id?: string;
|
||||||
srx?: number;
|
srx?: number;
|
||||||
stx?: number;
|
stx?: number;
|
||||||
@@ -5385,6 +5698,8 @@ export namespace qso {
|
|||||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||||
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
this.qrzcom_qso_download_date = source["qrzcom_qso_download_date"];
|
||||||
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
this.qrzcom_qso_download_status = source["qrzcom_qso_download_status"];
|
||||||
|
this.clublog_qso_download_date = source["clublog_qso_download_date"];
|
||||||
|
this.clublog_qso_download_status = source["clublog_qso_download_status"];
|
||||||
this.contest_id = source["contest_id"];
|
this.contest_id = source["contest_id"];
|
||||||
this.srx = source["srx"];
|
this.srx = source["srx"];
|
||||||
this.stx = source["stx"];
|
this.stx = source["stx"];
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
|||||||
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
||||||
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
||||||
|
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
||||||
|
|
||||||
// --- Contest ---
|
// --- Contest ---
|
||||||
w("CONTEST_ID", q.ContestID)
|
w("CONTEST_ID", q.ContestID)
|
||||||
@@ -441,5 +443,11 @@ func adifCounty(state, county string) string {
|
|||||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
// The "STATE,County" join is the ADIF secondary-subdivision format, and that
|
||||||
|
// enumeration is a US thing — a two-letter state code. Prefixing a Canadian
|
||||||
|
// "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere.
|
||||||
|
if len(s) != 2 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
return strings.ToUpper(s) + "," + c
|
return strings.ToUpper(s) + "," + c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ var Fields = []FieldDef{
|
|||||||
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
{Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
|
// App-defined pair (no standard ADIF field): Club Log's log-match download.
|
||||||
|
{Name: "CLUBLOG_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
|
||||||
|
{Name: "CLUBLOG_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
|
||||||
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
{Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
{Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
|
||||||
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
{Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ var adifPromoted = stringSet(
|
|||||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||||
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
|
||||||
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
|
||||||
|
"clublog_qso_download_date", "clublog_qso_download_status",
|
||||||
// Contest
|
// Contest
|
||||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||||
"check", "precedence", "arrl_sect",
|
"check", "precedence", "arrl_sect",
|
||||||
@@ -493,6 +494,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
|||||||
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
|
||||||
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
|
||||||
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
|
||||||
|
q.ClublogDownloadDate = rec["clublog_qso_download_date"]
|
||||||
|
q.ClublogDownloadStatus = rec["clublog_qso_download_status"]
|
||||||
|
|
||||||
// Contest
|
// Contest
|
||||||
q.ContestID = rec["contest_id"]
|
q.ContestID = rec["contest_id"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
|||||||
|
package autocall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const me = "F4BPO"
|
||||||
|
|
||||||
|
// base is a slot boundary, so slot parity in the tests is the real arithmetic.
|
||||||
|
var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) }
|
||||||
|
|
||||||
|
func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate {
|
||||||
|
c := Candidate{
|
||||||
|
Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true,
|
||||||
|
Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true},
|
||||||
|
Need: need,
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func watched(c *Candidate) { c.Watched = true }
|
||||||
|
func worked(c *Candidate) { c.Worked = true }
|
||||||
|
|
||||||
|
// busy is a station in the middle of an exchange with somebody else.
|
||||||
|
func busy(call string, need Need, snr int) Candidate {
|
||||||
|
c := cq(call, need, snr)
|
||||||
|
c.CQ = false
|
||||||
|
c.Msg = "VP6D " + call + " JN36"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsMe is a station answering us.
|
||||||
|
func callsMe(call string, need Need, snr int) Candidate {
|
||||||
|
c := cq(call, need, snr)
|
||||||
|
c.CQ = false
|
||||||
|
c.Msg = me + " " + call + " -12"
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func period(n int, decodes ...Candidate) Period {
|
||||||
|
for i := range decodes {
|
||||||
|
decodes[i].At = at(n)
|
||||||
|
}
|
||||||
|
return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me}
|
||||||
|
}
|
||||||
|
|
||||||
|
func on() *Engine { return New(Settings{Enabled: true}) }
|
||||||
|
|
||||||
|
// ── The ladder ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestLadderOrder(t *testing.T) {
|
||||||
|
// Every rung. 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{
|
||||||
|
cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
|
||||||
|
cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 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++ {
|
||||||
|
if rank(order[i-1]) <= rank(order[i]) {
|
||||||
|
t.Errorf("%s (%d) does not outrank %s (%d)",
|
||||||
|
order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A station with nothing needed and not watched is not called at all.
|
||||||
|
if rank(cq("Z", NeedNone, 0)) != 0 {
|
||||||
|
t.Error("a station with nothing to gain from it ranks above zero")
|
||||||
|
}
|
||||||
|
// And the pick agrees with the ladder, whatever order the period lists them.
|
||||||
|
e := on()
|
||||||
|
a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "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) {
|
||||||
|
e := on()
|
||||||
|
a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5)))
|
||||||
|
if a.Decode.Call != "LOUD" {
|
||||||
|
t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The busy station ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestBusyStationIsNeverCalled(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// The new entity is answering a DXpedition; the new band is calling CQ.
|
||||||
|
a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15)))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "DL1XX" {
|
||||||
|
t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a)
|
||||||
|
}
|
||||||
|
// It is not banned: the moment it calls CQ it takes the slot back, once the
|
||||||
|
// QSO in hand is over.
|
||||||
|
e2 := on()
|
||||||
|
if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("the same station calling CQ was not called: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalFrameIsCallable(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
c := busy("RARE", NeedDXCC, -3)
|
||||||
|
c.Msg = "IK2AAA RARE RR73" // one frame from being free
|
||||||
|
if a := e.OnPeriod(period(0, c)); a.Kind != DoReply {
|
||||||
|
t.Errorf("a station sending its last frame is free next period: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The brakes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAttemptsCapAtSevenAndFifteen(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
opt func(*Candidate)
|
||||||
|
want int
|
||||||
|
}{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt)))
|
||||||
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
for i := 1; i < tc.want; i++ {
|
||||||
|
if a := e.NoteTX(tx); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a := e.NoteTX(tx)
|
||||||
|
if a.Kind != DoHalt {
|
||||||
|
t.Errorf("%s: still calling after %d attempts", tc.name, tc.want)
|
||||||
|
}
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Errorf("%s: target still held after giving up", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyFreshCallsCount(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
// The rest of the exchange is not a call: without this the seven were spent
|
||||||
|
// on one QSO in progress.
|
||||||
|
for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} {
|
||||||
|
if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("%q ended the series", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Status().Attempts != 0 {
|
||||||
|
t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts)
|
||||||
|
}
|
||||||
|
// A transmission aimed at somebody else counts for nothing either.
|
||||||
|
e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"})
|
||||||
|
if e.Status().Attempts != 0 {
|
||||||
|
t.Errorf("a call to another station was counted against the target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet
|
||||||
|
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods
|
||||||
|
// Its listening periods say nothing about it. Ten of them must not add up
|
||||||
|
// to a give-up.
|
||||||
|
for _, p := range []int{3, 5, 7, 9, 11} {
|
||||||
|
if a := e.OnPeriod(period(p)); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("gave up during the station's own listening period %d", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Status().Misses != 0 {
|
||||||
|
t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses)
|
||||||
|
}
|
||||||
|
// Absent from two of its transmit periods: still holding.
|
||||||
|
e.OnPeriod(period(4))
|
||||||
|
e.OnPeriod(period(6))
|
||||||
|
if e.Target() == "" {
|
||||||
|
t.Fatal("gave up after two misses, the limit is three")
|
||||||
|
}
|
||||||
|
if a := e.OnPeriod(period(8)); a.Kind != DoHalt {
|
||||||
|
t.Errorf("still holding after three missed transmit periods: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
e.OnPeriod(period(2, cq("DX", NeedDXCC, -5)))
|
||||||
|
p := period(4)
|
||||||
|
e.OnPeriod(p)
|
||||||
|
e.OnPeriod(p)
|
||||||
|
e.OnPeriod(p)
|
||||||
|
if e.Status().Misses != 1 {
|
||||||
|
t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClockBackstopReleasesAStuckTarget(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
// Decoded every one of its periods and never answering, with the decoder
|
||||||
|
// never reporting a transmission: no counter can advance.
|
||||||
|
for p := 2; p <= 16; p += 2 {
|
||||||
|
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
|
||||||
|
}
|
||||||
|
if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt {
|
||||||
|
t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── After a series ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||||
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Fatal("still holding after seven calls")
|
||||||
|
}
|
||||||
|
// It is still there, and so is a new entity: the entity takes the slot.
|
||||||
|
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20)))
|
||||||
|
if a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||||
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
// Still the only thing on the air, and still resting: seven calls, a halt,
|
||||||
|
// and the same station called again four seconds later is not a rest.
|
||||||
|
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) {
|
||||||
|
e := on()
|
||||||
|
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++ {
|
||||||
|
a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
|
||||||
|
if a.Kind != DoReply {
|
||||||
|
t.Fatalf("round %d: not called (%+v)", round, a)
|
||||||
|
}
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Three series of seven is twenty-one calls. That is the end of it for this
|
||||||
|
// session — the whole point of the exercise is that it cannot reach fifty.
|
||||||
|
if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("a fourth series was started: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handing over ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
|
done.Msg = me + " DX RR73"
|
||||||
|
// 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" {
|
||||||
|
t.Errorf("next period: %+v, want the next priority", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
|
done.Msg = me + " DX RR73"
|
||||||
|
// Two stations calling us, nothing needed from either: the strongest wins —
|
||||||
|
// on the period after the one our 73 goes out in.
|
||||||
|
weak := callsMe("WEAK", NeedNone, -18)
|
||||||
|
weak.Watched = true
|
||||||
|
loud := callsMe("LOUD", NeedNone, -4)
|
||||||
|
loud.Watched = true
|
||||||
|
e.OnPeriod(period(2, done))
|
||||||
|
a := e.OnPeriod(period(4, weak, loud))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||||
|
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlreadyWorkedIsNeverCalled(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked)))
|
||||||
|
if a.Kind != DoNothing {
|
||||||
|
t.Errorf("called a station already in the log on this band and mode: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayedHistoryIsNeverCalled(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
old := cq("DX", NeedDXCC, -5)
|
||||||
|
old.IsNew = false
|
||||||
|
if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing {
|
||||||
|
t.Errorf("answered a replayed decode: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoCallStartsOverATransmissionInProgress(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
p := period(0, cq("DX", NeedDXCC, -5))
|
||||||
|
p.TX = TXState{Transmitting: true}
|
||||||
|
if a := e.OnPeriod(p); a.Kind != DoNothing {
|
||||||
|
t.Errorf("started a call while the decoder was transmitting: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The "call this station" field ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestOnlyCallsThatStation(t *testing.T) {
|
||||||
|
e := New(Settings{Enabled: true, Only: "VP6D"})
|
||||||
|
a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20)))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
|
||||||
|
t.Fatalf("picked %+v, want the station named in the field", a)
|
||||||
|
}
|
||||||
|
// Same brakes, then a hard stop: there is nothing else it was asked to do.
|
||||||
|
tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"}
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
if !e.Status().Stopped {
|
||||||
|
t.Error("an explicit target ran out of attempts and the feature did not stop")
|
||||||
|
}
|
||||||
|
if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("kept calling after the stop: %+v", a)
|
||||||
|
}
|
||||||
|
e.Reset()
|
||||||
|
if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply {
|
||||||
|
t.Errorf("the operator restarted it and nothing happened: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledDoesNothingAtAll(t *testing.T) {
|
||||||
|
e := New(Settings{})
|
||||||
|
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("switched off and still calling: %+v", a)
|
||||||
|
}
|
||||||
|
if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing {
|
||||||
|
t.Errorf("switched off and still counting: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) {
|
||||||
|
e := New(Settings{Enabled: true, Only: "VP6D"})
|
||||||
|
e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10)))
|
||||||
|
done := callsMe("VP6D", NeedDXCC, -10)
|
||||||
|
done.Msg = me + " VP6D RR73"
|
||||||
|
e.OnPeriod(period(2, done))
|
||||||
|
// The station is still on the air calling CQ. It has been worked: an
|
||||||
|
// explicit request is for one QSO, not for the whole evening.
|
||||||
|
if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("called the named station again after working it: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
|
done.Msg = me + " DX RR73"
|
||||||
|
e.OnPeriod(period(2, done))
|
||||||
|
// Still flagged as a new entity — the QSO is not in the log yet — and still
|
||||||
|
// calling CQ. It must not be answered again.
|
||||||
|
if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("called back a station worked two periods ago: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChaseListTakesSeveralCallsigns(t *testing.T) {
|
||||||
|
// Typed the way an operator types a list: commas, spaces, or both.
|
||||||
|
for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} {
|
||||||
|
if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" {
|
||||||
|
t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"})
|
||||||
|
// Nothing off the list is called, however rare it is.
|
||||||
|
if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("called a station that is not on the chase list: %+v", a)
|
||||||
|
}
|
||||||
|
// Between two listed stations the ladder still decides: the new entity over
|
||||||
|
// the new slot, whatever their order in the period.
|
||||||
|
a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22)))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "VP6D" {
|
||||||
|
t.Fatalf("picked %+v, want the new entity of the two listed", a)
|
||||||
|
}
|
||||||
|
// Working one of them leaves the other callable — the list is a hunt, not a
|
||||||
|
// single request.
|
||||||
|
done := callsMe("VP6D", NeedDXCC, -22)
|
||||||
|
done.Msg = me + " VP6D RR73"
|
||||||
|
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" {
|
||||||
|
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two decoders at once (the split view) ─────────────────────────────────
|
||||||
|
|
||||||
|
func onInst(inst string, p Period) Period {
|
||||||
|
p.Instance = inst
|
||||||
|
for i := range p.Decodes {
|
||||||
|
p.Decodes[i].Instance = inst
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// Calling a new band on receiver A.
|
||||||
|
if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" {
|
||||||
|
t.Fatalf("first call went to %+v", a)
|
||||||
|
}
|
||||||
|
// Receiver B now hears a new ENTITY — a better catch by every rule. It must
|
||||||
|
// still not be called: one station at a time, and the QSO in hand is the
|
||||||
|
// one already under way.
|
||||||
|
if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("the other receiver started a second QSO: %+v", a)
|
||||||
|
}
|
||||||
|
// And B's periods count no misses against A's target: the station is not
|
||||||
|
// absent from B, it was never on that band.
|
||||||
|
e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1))))
|
||||||
|
e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1))))
|
||||||
|
e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1))))
|
||||||
|
if e.Status().Misses != 0 || e.Target() != "DL1XX" {
|
||||||
|
t.Errorf("misses = %d, target = %q — the other receiver's periods were counted",
|
||||||
|
e.Status().Misses, e.Target())
|
||||||
|
}
|
||||||
|
// B transmitting its own QSO is not us calling DL1XX either.
|
||||||
|
for i := 0; i < 9; i++ {
|
||||||
|
e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"})
|
||||||
|
}
|
||||||
|
if e.Status().Attempts != 0 {
|
||||||
|
t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts)
|
||||||
|
}
|
||||||
|
// A's own transmissions do count.
|
||||||
|
e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"})
|
||||||
|
if e.Status().Attempts != 1 {
|
||||||
|
t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts)
|
||||||
|
}
|
||||||
|
// Once the QSO is over, the other receiver's station is free to be taken.
|
||||||
|
done := callsMe("DL1XX", NeedBand, -10)
|
||||||
|
done.Msg = me + " DL1XX RR73"
|
||||||
|
e.OnPeriod(onInst("A", period(9, done)))
|
||||||
|
if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-2
@@ -9,6 +9,7 @@ package cat
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -240,15 +241,81 @@ func (m *Manager) freqOffsetHz() int64 {
|
|||||||
// display trick: the readout would say 144 and every spot click, band change and
|
// display trick: the readout would say 144 and every spot click, band change and
|
||||||
// memory recall would send the rig somewhere 116 MHz away.
|
// memory recall would send the rig somewhere 116 MHz away.
|
||||||
func (m *Manager) SetFrequency(hz int64) error {
|
func (m *Manager) SetFrequency(hz int64) error {
|
||||||
|
real := hz
|
||||||
if off := m.freqOffsetHz(); off != 0 && hz > off {
|
if off := m.freqOffsetHz(); off != 0 && hz > off {
|
||||||
hz -= off
|
hz -= off
|
||||||
}
|
}
|
||||||
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||||
|
if err == nil {
|
||||||
|
m.noteCommandedFreq(real)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteCommandedFreq publishes a frequency the radio has just acknowledged,
|
||||||
|
// without waiting for the next poll to come round and read it back.
|
||||||
|
//
|
||||||
|
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
|
||||||
|
// frequency and then READS it back before it believes it is there, and until
|
||||||
|
// then it will not decode, transmit or even update its own dial. Everything
|
||||||
|
// answering "f" here comes from the last poll, so the answer was the OLD
|
||||||
|
// frequency for as long as a poll cycle takes; on a rig reached over the
|
||||||
|
// internet, where one cycle is many round trips, a band change from WSJT-X took
|
||||||
|
// ten seconds to be believed while the radio itself had moved instantly.
|
||||||
|
//
|
||||||
|
// Only when NOT split. In split the two frequencies mean different VFOs and a
|
||||||
|
// guess about which one just moved is how a client ends up writing the transmit
|
||||||
|
// frequency onto the dial — the poll is left to settle that case.
|
||||||
|
func (m *Manager) noteCommandedFreq(hz int64) {
|
||||||
|
if hz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
st := m.state
|
||||||
|
if !st.Connected || st.Split || st.FreqHz == hz {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.FreqHz = hz
|
||||||
|
st.Band = BandFromHz(hz)
|
||||||
|
st.UpdatedAt = time.Now()
|
||||||
|
m.state = st
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMode dispatches a SetMode call to the CAT goroutine.
|
// SetMode dispatches a SetMode call to the CAT goroutine.
|
||||||
func (m *Manager) SetMode(mode string) error {
|
func (m *Manager) SetMode(mode string) error {
|
||||||
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
err := m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||||
|
if err == nil {
|
||||||
|
m.noteCommandedMode(mode)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
|
||||||
|
// same client readback.
|
||||||
|
//
|
||||||
|
// "DATA" is deliberately not published. A backend reports data mode under the
|
||||||
|
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
|
||||||
|
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
|
||||||
|
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
|
||||||
|
// second away and knows the real name.
|
||||||
|
func (m *Manager) noteCommandedMode(mode string) {
|
||||||
|
if mode == "" || strings.EqualFold(mode, "DATA") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
st := m.state
|
||||||
|
if !st.Connected || st.Mode == mode {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.Mode = mode
|
||||||
|
st.UpdatedAt = time.Now()
|
||||||
|
m.state = st
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
|
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
|
||||||
@@ -256,6 +323,33 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataPTTSetter is implemented by a backend that can key the DATA input rather
|
||||||
|
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
|
||||||
|
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
|
||||||
|
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
|
||||||
|
// because the audio arriving on USB is simply not the input it is listening to.
|
||||||
|
type dataPTTSetter interface {
|
||||||
|
SetPTTData(on bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
|
||||||
|
//
|
||||||
|
// data=true means "the audio reaches the radio on its data/USB input" — what a
|
||||||
|
// voice keyer playing through the rig's own sound card needs. A backend that
|
||||||
|
// draws no distinction (every rig where one PTT is all there is) falls back to
|
||||||
|
// the ordinary key, so nothing changes for it.
|
||||||
|
func (m *Manager) SetPTTSource(on, data bool) error {
|
||||||
|
if !data {
|
||||||
|
return m.SetPTT(on)
|
||||||
|
}
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
if d, ok := b.(dataPTTSetter); ok {
|
||||||
|
return d.SetPTTData(on)
|
||||||
|
}
|
||||||
|
return b.SetPTT(on)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// splitSetter is implemented by the backends that can arm split AND place the
|
// splitSetter is implemented by the backends that can arm split AND place the
|
||||||
// transmit frequency. Both together: arming without setting the dial transmits
|
// transmit frequency. Both together: arming without setting the dial transmits
|
||||||
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
||||||
@@ -304,6 +398,14 @@ type SpotInfo struct {
|
|||||||
BackgroundColor string
|
BackgroundColor string
|
||||||
Comment string
|
Comment string
|
||||||
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||||
|
// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
|
||||||
|
//
|
||||||
|
// It matters because the panadapter has finite room: spots close in
|
||||||
|
// frequency are stacked behind a "+" and only one of them is drawn. The
|
||||||
|
// radio picks that one by priority — so an entity never worked can sit
|
||||||
|
// invisible behind three stations already in the log unless we say which
|
||||||
|
// is worth the space. 0 leaves the field off the command entirely.
|
||||||
|
Priority int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
@@ -669,6 +771,10 @@ type IcomController interface {
|
|||||||
SetVOXGain(int) error
|
SetVOXGain(int) error
|
||||||
SetAntiVOX(int) error
|
SetAntiVOX(int) error
|
||||||
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
|
||||||
|
// RecallBandStack moves the VFO to what the radio's own band stacking
|
||||||
|
// register holds — the operator's last frequency and mode on that band.
|
||||||
|
// Returns the frequency landed on.
|
||||||
|
RecallBandStack(band, reg int) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||||
|
|||||||
@@ -425,3 +425,49 @@ func indexPreamble(buf []byte, from int) int {
|
|||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Band stacking registers (CI-V 0x1A sub 0x01) ──────────────────────────
|
||||||
|
//
|
||||||
|
// Every modern Icom remembers the last few frequency/mode pairs used on each
|
||||||
|
// band, and its front-panel band key walks through them. That is why pressing
|
||||||
|
// [14] on the radio lands on the FT8 watering hole rather than on a number
|
||||||
|
// somebody chose in software: the register is the operator's OWN last visit.
|
||||||
|
//
|
||||||
|
// The frame is a READ — it asks the rig what a register holds and changes
|
||||||
|
// nothing — so a rig that does not know the command answers NG and the caller
|
||||||
|
// is exactly where it started.
|
||||||
|
//
|
||||||
|
// → 1A 01 <band> <reg>
|
||||||
|
// ← 1A 01 <band> <reg> <freq 5 BCD, LE> <mode> <filter> <data mode> …
|
||||||
|
//
|
||||||
|
// Anything past the data-mode byte (duplex, tone, DV squelch on the VHF rigs)
|
||||||
|
// is not read here: the question is where the operator last was, and the answer
|
||||||
|
// to that is the frequency and the mode.
|
||||||
|
const SubBandStack = 0x01
|
||||||
|
|
||||||
|
// BandStack is one register's contents.
|
||||||
|
type BandStack struct {
|
||||||
|
FreqHz int64
|
||||||
|
Mode byte
|
||||||
|
Data bool // the data-mode flag that goes with Mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeBandStack reads the payload of a 0x1A 0x01 reply, i.e. everything after
|
||||||
|
// the command byte. ok is false for a frame that is not the register asked for,
|
||||||
|
// which is what a desynchronised read looks like.
|
||||||
|
func DecodeBandStack(data []byte, band, reg byte) (BandStack, bool) {
|
||||||
|
if len(data) < 9 || data[0] != SubBandStack || data[1] != band || data[2] != reg {
|
||||||
|
return BandStack{}, false
|
||||||
|
}
|
||||||
|
hz, ok := BCDToFreq(data[3:8])
|
||||||
|
if !ok || hz <= 0 {
|
||||||
|
return BandStack{}, false
|
||||||
|
}
|
||||||
|
bs := BandStack{FreqHz: hz, Mode: data[8]}
|
||||||
|
// Filter then data mode. A rig that stops at the filter byte is not an
|
||||||
|
// error — it is a register without a data flag, so the flag stays false.
|
||||||
|
if len(data) >= 11 {
|
||||||
|
bs.Data = data[10] != 0
|
||||||
|
}
|
||||||
|
return bs, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,3 +204,36 @@ func TestBCDToFreqRejectsNonDecimal(t *testing.T) {
|
|||||||
t.Error("a short but valid BCD frame must still decode")
|
t.Error("a short but valid BCD frame must still decode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A band stacking register reply, byte for byte as the rigs send it:
|
||||||
|
// 1A 01 <band> <reg> <freq 5 LE-BCD> <mode> <filter> <data>. The payload here
|
||||||
|
// is everything after the command byte, which is what Decoded.Data holds.
|
||||||
|
func TestDecodeBandStack(t *testing.T) {
|
||||||
|
// 14.074.000 MHz, USB, FIL1, data mode on — the FT8 stack on 20 m.
|
||||||
|
frame := []byte{0x01, 0x05, 0x03, 0x00, 0x40, 0x07, 0x14, 0x00, ModeUSB, 0x01, 0x01}
|
||||||
|
bs, ok := DecodeBandStack(frame, 0x05, 0x03)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a well-formed register was rejected")
|
||||||
|
}
|
||||||
|
if bs.FreqHz != 14_074_000 {
|
||||||
|
t.Errorf("freq = %d, want 14074000", bs.FreqHz)
|
||||||
|
}
|
||||||
|
if bs.Mode != ModeUSB || !bs.Data {
|
||||||
|
t.Errorf("mode = 0x%02X data = %v, want USB + data", bs.Mode, bs.Data)
|
||||||
|
}
|
||||||
|
// The register ASKED FOR is part of the answer: a reply about another one is
|
||||||
|
// a desynchronised read, not a frequency to send the radio to.
|
||||||
|
if _, ok := DecodeBandStack(frame, 0x05, 0x01); ok {
|
||||||
|
t.Error("a reply for register 3 was accepted as register 1")
|
||||||
|
}
|
||||||
|
if _, ok := DecodeBandStack(frame, 0x03, 0x03); ok {
|
||||||
|
t.Error("a reply about 40 m was accepted as 20 m")
|
||||||
|
}
|
||||||
|
// A register without the data-mode byte is a shorter frame, not a bad one.
|
||||||
|
if bs, ok := DecodeBandStack(frame[:10], 0x05, 0x03); !ok || bs.FreqHz != 14_074_000 || bs.Data {
|
||||||
|
t.Errorf("short register = %+v ok=%v, want the frequency with no data flag", bs, ok)
|
||||||
|
}
|
||||||
|
if _, ok := DecodeBandStack([]byte{0x01, 0x05, 0x03}, 0x05, 0x03); ok {
|
||||||
|
t.Error("a truncated frame was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1557,6 +1557,10 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if hadOld {
|
if hadOld {
|
||||||
f.send(fmt.Sprintf("spot remove %d", old))
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
}
|
}
|
||||||
|
prio := ""
|
||||||
|
if s.Priority >= 1 && s.Priority <= 5 {
|
||||||
|
prio = fmt.Sprintf(" priority=%d", s.Priority)
|
||||||
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
|
||||||
@@ -1575,6 +1579,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if c := flexEncode(s.Comment); c != "" {
|
if c := flexEncode(s.Comment); c != "" {
|
||||||
cmd += " comment=" + c
|
cmd += " comment=" + c
|
||||||
}
|
}
|
||||||
|
cmd += prio
|
||||||
seq := f.send(cmd)
|
seq := f.send(cmd)
|
||||||
if seq > 0 {
|
if seq > 0 {
|
||||||
// Remember which call this add was for; the R<seq> response carries the
|
// Remember which call this add was for; the R<seq> response carries the
|
||||||
|
|||||||
@@ -594,6 +594,15 @@ func (b *IcomSerial) SetMode(mode string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return b.setModeBytes(mode, code, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
|
||||||
|
// Split out for the band-stacking recall, which gets both FROM the radio and
|
||||||
|
// must not go back through an ADIF name to reach them: a register holding CW-R
|
||||||
|
// or LSB would come back as plain CW or as whatever the band convention says,
|
||||||
|
// i.e. not the mode the operator left there.
|
||||||
|
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
|
||||||
// Set the base mode (keeping the rig's current filter by sending only the
|
// Set the base mode (keeping the rig's current filter by sending only the
|
||||||
// mode byte), then set the data-mode flag for digital modes.
|
// mode byte), then set the data-mode flag for digital modes.
|
||||||
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
||||||
@@ -1543,6 +1552,14 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
|||||||
return civ.ModeCW, false, nil
|
return civ.ModeCW, false, nil
|
||||||
case "SSB":
|
case "SSB":
|
||||||
return usb, false, nil
|
return usb, false, nil
|
||||||
|
case "USB":
|
||||||
|
// The SIDEBAND, asked for by name. "SSB" resolves to whichever side the
|
||||||
|
// band convention wants, which is right for a logged mode and useless
|
||||||
|
// when the operator means "put this radio in USB" — on 40 m there was no
|
||||||
|
// way to say it at all, and the console's own button could not either.
|
||||||
|
return civ.ModeUSB, false, nil
|
||||||
|
case "LSB":
|
||||||
|
return civ.ModeLSB, false, nil
|
||||||
case "AM":
|
case "AM":
|
||||||
return civ.ModeAM, false, nil
|
return civ.ModeAM, false, nil
|
||||||
case "FM":
|
case "FM":
|
||||||
@@ -2180,3 +2197,59 @@ func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
|
return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Band stacking registers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// RecallBandStack puts the VFO where the operator last was on a band, by asking
|
||||||
|
// the radio rather than by holding an opinion about it.
|
||||||
|
//
|
||||||
|
// The console's band buttons used to send a frequency chosen in software — a
|
||||||
|
// reasonable middle-of-the-band number, and never where anybody actually
|
||||||
|
// operates. The radio already knows better: every band key press it has ever
|
||||||
|
// had is remembered in that band's stacking registers, so register 1 is the
|
||||||
|
// last place used on that band, and cycling through 2 and 3 walks back through
|
||||||
|
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
|
||||||
|
// was worked, without either being written down anywhere.
|
||||||
|
//
|
||||||
|
// Reads the register, then sets frequency and mode from it. Returns the
|
||||||
|
// frequency it landed on, so the caller can say where it went; a register the
|
||||||
|
// rig will not read leaves the radio untouched and returns an error, which is
|
||||||
|
// what makes the caller's fallback to a plain frequency safe.
|
||||||
|
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
|
||||||
|
if b.port == nil {
|
||||||
|
return 0, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if band <= 0 || reg < 1 || reg > 3 {
|
||||||
|
return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
|
||||||
|
}
|
||||||
|
bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
|
||||||
|
if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
|
||||||
|
if !ok {
|
||||||
|
// Logged with the raw frame: the register layout has a tail that differs
|
||||||
|
// between models, and a rig that answers something we cannot read is the
|
||||||
|
// one thing worth seeing here.
|
||||||
|
applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
|
||||||
|
return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
|
||||||
|
}
|
||||||
|
if err := b.SetFrequency(bs.FreqHz); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// The mode is best-effort. Landing on the right frequency in the wrong mode
|
||||||
|
// is a nuisance; refusing the whole recall over it would send the operator
|
||||||
|
// back to a button that does less.
|
||||||
|
if bs.Mode != 0 {
|
||||||
|
if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
|
||||||
|
applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bs.FreqHz, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
|
|||||||
return k.write("RX;")
|
return k.write("RX;")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
|
||||||
|
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
|
||||||
|
// that the parameter chooses the input — "0: SEND (normal transmission using
|
||||||
|
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
|
||||||
|
// into the rig's USB codec has to say TX1 or it transmits dead air while the
|
||||||
|
// radio listens to a microphone nobody is speaking into.
|
||||||
|
//
|
||||||
|
// Unkeying is the same RX either way; there is no data-flavoured stop.
|
||||||
|
func (k *Kenwood) SetPTTData(on bool) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
k.tx = on
|
||||||
|
if on {
|
||||||
|
k.txAt = time.Now()
|
||||||
|
return k.write("TX1;")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
func (k *Kenwood) write(cmd string) error {
|
func (k *Kenwood) write(cmd string) error {
|
||||||
if k.port == nil {
|
if k.port == nil {
|
||||||
return fmt.Errorf("kenwood: not connected")
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
|||||||
@@ -52,9 +52,15 @@ type KenwoodTXState struct {
|
|||||||
SMeterRaw int `json:"s_meter_raw"`
|
SMeterRaw int `json:"s_meter_raw"`
|
||||||
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
|
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
|
||||||
// measured", NOT a perfect match.
|
// measured", NOT a perfect match.
|
||||||
PowerMeter int `json:"power_meter"`
|
PowerMeter int `json:"power_meter"`
|
||||||
SWR float64 `json:"swr"`
|
// PowerW is the transmit power in WATTS, derived from the bargraph and the
|
||||||
SWRRaw int `json:"swr_raw"`
|
// meter's RANGE. The K3's bar is relative to a range that flips at 12 W —
|
||||||
|
// calibrated against a real one: 10 W showed 83 (10/12), 100 W showed 83
|
||||||
|
// too (100/120). The bar alone never was watts; with the PC setting to
|
||||||
|
// pick the range, it converts. 0 while receiving.
|
||||||
|
PowerW int `json:"power_w"`
|
||||||
|
SWR float64 `json:"swr"`
|
||||||
|
SWRRaw int `json:"swr_raw"`
|
||||||
|
|
||||||
RFPower int `json:"rf_power"` // watts, the PC setting
|
RFPower int `json:"rf_power"` // watts, the PC setting
|
||||||
AFGain int `json:"af_gain"` // 0-100
|
AFGain int `json:"af_gain"` // 0-100
|
||||||
@@ -162,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
|
|||||||
// Cleared, not frozen: a power bar left standing after the carrier drops
|
// Cleared, not frozen: a power bar left standing after the carrier drops
|
||||||
// reads as a live transmission.
|
// reads as a live transmission.
|
||||||
k.panel.PowerMeter = 0
|
k.panel.PowerMeter = 0
|
||||||
|
k.panel.PowerW = 0
|
||||||
k.panel.SWR, k.panel.SWRRaw = 0, 0
|
k.panel.SWR, k.panel.SWRRaw = 0, 0
|
||||||
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
|
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
|
||||||
// The S-meter only means anything while receiving.
|
// The S-meter only means anything while receiving.
|
||||||
@@ -341,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
|
|||||||
defer func() { k.noLatch = false }()
|
defer func() { k.noLatch = false }()
|
||||||
if v, ok := k.askNum("BG;", "BG", 2); ok {
|
if v, ok := k.askNum("BG;", "BG", 2); ok {
|
||||||
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
|
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
|
||||||
|
if k.elecraft {
|
||||||
|
scale := 120
|
||||||
|
if k.panel.RFPower > 0 && k.panel.RFPower <= 12 {
|
||||||
|
scale = 12 // the K3's QRP range
|
||||||
|
}
|
||||||
|
k.panel.PowerW = k.panel.PowerMeter * scale / 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
|
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
|
||||||
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
|
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
|
||||||
|
|||||||
@@ -388,7 +388,14 @@ func (y *Yaesu) RefreshYaesu() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetYaesuPower(w int) error {
|
func (y *Yaesu) SetYaesuPower(w int) error {
|
||||||
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
|
// The SAME ceiling the console draws its slider to. It was hard-coded at 100
|
||||||
|
// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
|
||||||
|
// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
|
||||||
|
// next poll — which is how the operator discovered it.
|
||||||
|
y.mu.Lock()
|
||||||
|
max := yaesuMaxPower(y.model, y.panel.RFPower)
|
||||||
|
y.mu.Unlock()
|
||||||
|
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
func (y *Yaesu) SetYaesuMicGain(p int) error {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Reported on an FTDX101MP: the power slider sprang back to 100 W. The console
|
||||||
|
// already knew the rig could do 200 — yaesuMaxPower says so — but the SET path
|
||||||
|
// clamped to 100, so the radio was politely given half what was asked for.
|
||||||
|
func TestYaesuPowerCeilingFollowsTheModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"FTDX101MP", 200},
|
||||||
|
{"FT-DX5000", 200},
|
||||||
|
{"FTDX9000", 200},
|
||||||
|
{"FTDX101D", 100},
|
||||||
|
{"FTDX10", 100},
|
||||||
|
{"Yaesu (0999)", 100}, // unknown: ask for too little, never too much
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := yaesuMaxPower(c.model, 0); got != c.want {
|
||||||
|
t.Errorf("%s ceiling = %d W, want %d", c.model, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A rig REPORTING more than the table expects has just proved what it can do.
|
||||||
|
if got := yaesuMaxPower("FTDX10", 150); got != 150 {
|
||||||
|
t.Errorf("a rig reporting 150 W was capped at %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
|||||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
|
||||||
|
// already there, or the statement shapes a table this database does not hold.
|
||||||
|
// Scoped to shaping statements only — a CREATE TABLE or data statement that
|
||||||
|
// fails must still fail the migration.
|
||||||
|
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "no such table") {
|
||||||
|
head := strings.ToLower(strings.TrimSpace(stmt))
|
||||||
|
return strings.HasPrefix(head, "alter table") ||
|
||||||
|
strings.HasPrefix(head, "create index") ||
|
||||||
|
strings.HasPrefix(head, "create unique index") ||
|
||||||
|
strings.HasPrefix(head, "drop ")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||||
// skipping those already applied. Intentionally minimal in-house system
|
// skipping those already applied. Intentionally minimal in-house system
|
||||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||||
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
|
// Same self-healing as the MySQL path, plus one case it never
|
||||||
|
// meets: a table-shaping statement aimed at a table this
|
||||||
|
// database legitimately does not have. A split settings
|
||||||
|
// database dropped its qso table when the QSOs moved to the
|
||||||
|
// logbook, but its role is still RoleAll — so a later
|
||||||
|
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
|
||||||
|
// failure that silently kills the whole startup (v0.27.4+).
|
||||||
|
if isIgnorableSQLiteDDLError(err, stmt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
_ = tx.Rollback()
|
_ = tx.Rollback()
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Club Log log-matching (getmatches.php): a QSO both sides uploaded to Club
|
||||||
|
-- Log is a confirmation in its own right. Mirrors qrzcom_qso_download_*.
|
||||||
|
ALTER TABLE qso ADD COLUMN clublog_qso_download_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN clublog_qso_download_status TEXT;
|
||||||
+11
-1
@@ -40,6 +40,14 @@ type Match struct {
|
|||||||
Continent string `json:"continent"`
|
Continent string `json:"continent"`
|
||||||
Lat float64 `json:"lat"`
|
Lat float64 `json:"lat"`
|
||||||
Lon float64 `json:"lon"`
|
Lon float64 `json:"lon"`
|
||||||
|
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
|
||||||
|
//
|
||||||
|
// The two carry very different authority. A prefix match is a rule of thumb
|
||||||
|
// about a block of callsigns; an exact entry is somebody having looked at
|
||||||
|
// THIS callsign and written down where it was. A second country file may
|
||||||
|
// improve on the first kind and should not be allowed to overrule the
|
||||||
|
// second.
|
||||||
|
Exact bool `json:"exact,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type prefixEntry struct {
|
type prefixEntry struct {
|
||||||
@@ -149,7 +157,9 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
|
|||||||
return Match{}, false
|
return Match{}, false
|
||||||
}
|
}
|
||||||
if e, ok := db.exact[call]; ok {
|
if e, ok := db.exact[call]; ok {
|
||||||
return materialize(e), true
|
m := materialize(e)
|
||||||
|
m.Exact = true
|
||||||
|
return m, true
|
||||||
}
|
}
|
||||||
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
|
||||||
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// Package dxped reads the two feeds the DX world announces itself on.
|
||||||
|
//
|
||||||
|
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
|
||||||
|
// description is a fixed, dash-separated sentence — dates, entity, callsign,
|
||||||
|
// QSL route, source, then the operators/bands/modes prose. It is what a
|
||||||
|
// DXpedition list is actually made of.
|
||||||
|
//
|
||||||
|
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
|
||||||
|
// carries no structure to act on, but the headline nearly always names the
|
||||||
|
// callsign, so the calls are mined from the title and the reader can act on
|
||||||
|
// them the same way (watchlist, chase status).
|
||||||
|
//
|
||||||
|
// Both are cached: the announcements change a few times a day, the news a few
|
||||||
|
// times an hour, and neither is worth a request per screen repaint.
|
||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
adxoURL = "https://www.ng3k.com/adxo.xml"
|
||||||
|
dxworldURL = "https://dx-world.net/feed/"
|
||||||
|
|
||||||
|
adxoTTL = 1 * time.Hour
|
||||||
|
dxworldTTL = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Activation is one announced operation, as ADXO describes it.
|
||||||
|
type Activation struct {
|
||||||
|
DXCC string `json:"dxcc"`
|
||||||
|
Callsign string `json:"callsign"` // display form; "A, B" when several
|
||||||
|
Calls []string `json:"calls"` // the individual callsigns, normalised
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Bands []string `json:"bands"`
|
||||||
|
Modes []string `json:"modes"`
|
||||||
|
QSL string `json:"qsl"`
|
||||||
|
Operators string `json:"operators"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Status string `json:"status"` // "active" | "upcoming"
|
||||||
|
}
|
||||||
|
|
||||||
|
// News is one DX-World post.
|
||||||
|
type News struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
|
||||||
|
Calls []string `json:"calls"` // callsigns mined from the headline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager holds both caches and fetches on demand.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
acts []Activation
|
||||||
|
actsAt time.Time
|
||||||
|
news []News
|
||||||
|
newsAt time.Time
|
||||||
|
client *http.Client
|
||||||
|
fetching sync.Mutex // one refresh at a time, whichever pane asked
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager {
|
||||||
|
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activations returns the cached announcements, refreshing when stale.
|
||||||
|
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out := m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
// Someone else may have refreshed while we waited for the lock.
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||||||
|
out = m.acts
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
acts, err := m.fetchADXO(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Stale beats empty: a feed that is down should not blank a list the
|
||||||
|
// operator was reading a minute ago.
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.acts, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.acts, m.actsAt = acts, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return acts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// News returns the cached DX-World posts, refreshing when stale.
|
||||||
|
func (m *Manager) News(ctx context.Context) ([]News, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out := m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
m.fetching.Lock()
|
||||||
|
defer m.fetching.Unlock()
|
||||||
|
m.mu.RLock()
|
||||||
|
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||||||
|
out = m.news
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
news, err := m.fetchDXWorld(ctx)
|
||||||
|
if err != nil {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.news, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.news, m.newsAt = news, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return news, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate drops both caches so the next read refetches.
|
||||||
|
func (m *Manager) Invalidate() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.actsAt, m.newsAt = time.Time{}, time.Time{}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ADXO ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type rssItem struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Description string `xml:"description"`
|
||||||
|
Link string `xml:"link"`
|
||||||
|
PubDate string `xml:"pubDate"`
|
||||||
|
Creator string `xml:"creator"`
|
||||||
|
Enclosure struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"enclosure"`
|
||||||
|
MediaContent struct {
|
||||||
|
URL string `xml:"url,attr"`
|
||||||
|
} `xml:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rssFeed struct {
|
||||||
|
Items []rssItem `xml:"channel>item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
|
||||||
|
body, err := m.get(ctx, adxoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("adxo: parse: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]Activation, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
a := parseActivation(it.Description, it.Link)
|
||||||
|
if a == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.Status = activationStatus(a.StartDate, a.EndDate, now)
|
||||||
|
if a.Status == "ended" {
|
||||||
|
continue // the list is about what is on or coming
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var spaceRe = regexp.MustCompile(`\s+`)
|
||||||
|
|
||||||
|
// parseActivation reads one ADXO description. Its shape is fixed and has been
|
||||||
|
// for twenty years:
|
||||||
|
//
|
||||||
|
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
|
||||||
|
// -- By ops; bands; modes; notes"
|
||||||
|
func parseActivation(desc, link string) *Activation {
|
||||||
|
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
|
||||||
|
desc = strings.TrimSpace(html.UnescapeString(desc))
|
||||||
|
if desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(desc, " -- ")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := &Activation{Link: link}
|
||||||
|
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
|
||||||
|
a.DXCC = strings.TrimSpace(parts[1])
|
||||||
|
a.Callsign = strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
for _, p := range parts[3:] {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "QSL:"):
|
||||||
|
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
|
||||||
|
case strings.HasPrefix(p, "Source:"):
|
||||||
|
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
|
||||||
|
tail := parts[len(parts)-1]
|
||||||
|
if i := strings.Index(tail, "By "); i >= 0 {
|
||||||
|
sub := strings.Split(tail[i+3:], ";")
|
||||||
|
if len(sub) > 0 {
|
||||||
|
a.Operators = strings.TrimSpace(sub[0])
|
||||||
|
}
|
||||||
|
if len(sub) > 1 {
|
||||||
|
a.Bands = parseBands(sub[1])
|
||||||
|
}
|
||||||
|
if len(sub) > 2 {
|
||||||
|
a.Modes = parseModes(sub[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign field often holds only the PREFIX; the real calls hide in the
|
||||||
|
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
|
||||||
|
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
|
||||||
|
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
|
||||||
|
for i := range calls {
|
||||||
|
calls[i] = normalizeCall(calls[i], prefix)
|
||||||
|
}
|
||||||
|
a.Calls = calls
|
||||||
|
a.Callsign = strings.Join(calls, ", ")
|
||||||
|
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
|
||||||
|
a.Calls = []string{c}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
|
||||||
|
// "Mar 3-20, 2026" (the month carried over).
|
||||||
|
var (
|
||||||
|
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
|
||||||
|
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
|
||||||
|
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDateRange(s string) (start, end string) {
|
||||||
|
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[3], m[2] + ", " + m[3]
|
||||||
|
}
|
||||||
|
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
|
||||||
|
}
|
||||||
|
if m := singleDayRe.FindStringSubmatch(s); m != nil {
|
||||||
|
return m[1] + ", " + m[2], m[1] + ", " + m[2]
|
||||||
|
}
|
||||||
|
return s, s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseADXODate(s string) (time.Time, bool) {
|
||||||
|
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
|
||||||
|
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func activationStatus(start, end string, now time.Time) string {
|
||||||
|
s, okS := parseADXODate(start)
|
||||||
|
e, okE := parseADXODate(end)
|
||||||
|
if !okS || !okE {
|
||||||
|
return "upcoming" // unreadable dates: keep it, an operator can read them
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case now.Before(s):
|
||||||
|
return "upcoming"
|
||||||
|
// The end date is a DAY, so an operation is on until that day is over.
|
||||||
|
case now.After(e.Add(24 * time.Hour)):
|
||||||
|
return "ended"
|
||||||
|
default:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsAfterAs mines "…as CALL…" out of the operators prose:
|
||||||
|
//
|
||||||
|
// "W2APF as PJ2/W2APF" → PJ2/W2APF
|
||||||
|
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
|
||||||
|
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
|
||||||
|
|
||||||
|
func callsAfterAs(operators string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(m[1]))
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
|
||||||
|
// matching the log against JG8NQJ/JD1 would find nothing.
|
||||||
|
func normalizeCall(call, dxccPrefix string) string {
|
||||||
|
if dxccPrefix == "" || !strings.Contains(call, "/") {
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
left, right, _ := strings.Cut(call, "/")
|
||||||
|
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
|
||||||
|
return right + "/" + left
|
||||||
|
}
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// A span carries its unit only once — "160-6m" is the commonest way ADXO
|
||||||
|
// states coverage, and reading it as "6m" alone loses the whole low end.
|
||||||
|
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
|
||||||
|
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
|
||||||
|
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
|
||||||
|
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseBands(s string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(num, unit string) {
|
||||||
|
if num == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := strings.ToLower(num + unit)
|
||||||
|
if seen[b] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[b] = true
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
// A span keeps only its endpoints: ADXO states coverage in prose, and the
|
||||||
|
// two ends are the part it gives reliably.
|
||||||
|
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
|
||||||
|
add(m[1], m[3])
|
||||||
|
add(m[2], m[3])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModes(s string) []string {
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, mode := range knownModes {
|
||||||
|
if seen[mode] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
|
||||||
|
seen[mode] = true
|
||||||
|
out = append(out, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DX-World ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func stripHTML(s string) string {
|
||||||
|
s = htmlTagRe.ReplaceAllString(s, " ")
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
|
||||||
|
body, err := m.get(ctx, dxworldURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: %w", err)
|
||||||
|
}
|
||||||
|
var feed rssFeed
|
||||||
|
if err := xml.Unmarshal(body, &feed); err != nil {
|
||||||
|
return nil, fmt.Errorf("dx-world: parse: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]News, 0, len(feed.Items))
|
||||||
|
for _, it := range feed.Items {
|
||||||
|
title := stripHTML(it.Title)
|
||||||
|
tag, title := splitTag(title)
|
||||||
|
n := News{
|
||||||
|
Title: title,
|
||||||
|
Link: strings.TrimSpace(it.Link),
|
||||||
|
Creator: strings.TrimSpace(it.Creator),
|
||||||
|
Tag: tag,
|
||||||
|
Calls: callsInHeadline(title),
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
|
||||||
|
n.PubDate = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
ex := stripHTML(it.Description)
|
||||||
|
if t2, rest := splitTag(ex); t2 != "" {
|
||||||
|
if n.Tag == "" {
|
||||||
|
n.Tag = t2
|
||||||
|
}
|
||||||
|
ex = rest
|
||||||
|
}
|
||||||
|
n.Excerpt = truncateRunes(ex, 400)
|
||||||
|
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
|
||||||
|
n.ImageURL = u
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
|
||||||
|
func splitTag(s string) (tag, rest string) {
|
||||||
|
m := tagPrefixRe.FindStringSubmatchIndex(s)
|
||||||
|
if m == nil {
|
||||||
|
return "", s
|
||||||
|
}
|
||||||
|
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
|
||||||
|
rest = strings.TrimSpace(s[m[1]:])
|
||||||
|
rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ")
|
||||||
|
return tag, strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, max int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r[:max])) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
|
||||||
|
// or "TX5S team lands". The reader can then chase or watch them, which is the
|
||||||
|
// whole reason a news feed sits next to the announcements.
|
||||||
|
func callsInHeadline(title string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
|
||||||
|
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
|
||||||
|
}) {
|
||||||
|
c := strings.ToUpper(tok)
|
||||||
|
if !plausibleCall(c) || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
|
||||||
|
// Jargon shaped exactly like a callsign. Every one of these was seen in a
|
||||||
|
// real headline before it earned its place here.
|
||||||
|
notACall = map[string]bool{
|
||||||
|
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
|
||||||
|
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
|
||||||
|
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
|
||||||
|
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters
|
||||||
|
// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one
|
||||||
|
// digit, and a letter somewhere after the first digit — which is what separates
|
||||||
|
// a callsign from a band or a year.
|
||||||
|
func plausibleCall(s string) bool {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Judge the longest part — the real call in "PJ2/W2APF" either way.
|
||||||
|
base := s
|
||||||
|
if strings.Contains(s, "/") {
|
||||||
|
base = ""
|
||||||
|
for _, p := range strings.Split(s, "/") {
|
||||||
|
if len(p) > len(base) {
|
||||||
|
base = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hasLetter, hasDigit, letterAfterDigit bool
|
||||||
|
seenDigit := false
|
||||||
|
for _, r := range base {
|
||||||
|
switch {
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
hasLetter = true
|
||||||
|
if seenDigit {
|
||||||
|
letterAfterDigit = true
|
||||||
|
}
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
seenDigit = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit && letterAfterDigit
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dxped
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pinned against REAL feed text: the parser reads a fixed sentence, and a
|
||||||
|
// wrong split silently empties a DXpedition list rather than failing loudly.
|
||||||
|
func TestParseActivation(t *testing.T) {
|
||||||
|
desc := "Aug 24-31, 2026 -- St Kitts and Nevis -- V47JA -- QSL: LoTW -- " +
|
||||||
|
"Source: W5JON (Aug 1, 2026) -- By W5JON as V47JA fm Calypso Bay; 160-6m; SSB FT8; yagi, verticals"
|
||||||
|
a := parseActivation(desc, "https://www.qrz.com/lookup/v47ja")
|
||||||
|
if a == nil {
|
||||||
|
t.Fatal("parseActivation returned nil on a real ADXO description")
|
||||||
|
}
|
||||||
|
if a.DXCC != "St Kitts and Nevis" {
|
||||||
|
t.Errorf("DXCC = %q", a.DXCC)
|
||||||
|
}
|
||||||
|
if a.Callsign != "V47JA" {
|
||||||
|
t.Errorf("Callsign = %q, want V47JA (mined from 'as')", a.Callsign)
|
||||||
|
}
|
||||||
|
if a.QSL != "LoTW" {
|
||||||
|
t.Errorf("QSL = %q", a.QSL)
|
||||||
|
}
|
||||||
|
if a.StartDate != "Aug 24, 2026" || a.EndDate != "Aug 31, 2026" {
|
||||||
|
t.Errorf("dates = %q..%q", a.StartDate, a.EndDate)
|
||||||
|
}
|
||||||
|
if want := []string{"160m", "6m"}; !reflect.DeepEqual(a.Bands, want) {
|
||||||
|
t.Errorf("Bands = %v, want %v", a.Bands, want)
|
||||||
|
}
|
||||||
|
if want := []string{"SSB", "FT8"}; !reflect.DeepEqual(a.Modes, want) {
|
||||||
|
t.Errorf("Modes = %v, want %v", a.Modes, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The callsign column often holds only the prefix; the operators prose holds
|
||||||
|
// the real thing, and a slashed call must lead with the DXCC prefix or no spot
|
||||||
|
// will ever match it.
|
||||||
|
func TestCallsAfterAsAndNormalise(t *testing.T) {
|
||||||
|
if got := callsAfterAs("SQ2RAD as VP2EAD, M0PLX as VP2ELX"); !reflect.DeepEqual(got, []string{"VP2EAD", "VP2ELX"}) {
|
||||||
|
t.Errorf("callsAfterAs = %v", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("JG8NQJ/JD1", "JD1"); got != "JD1/JG8NQJ" {
|
||||||
|
t.Errorf("normalizeCall = %q, want JD1/JG8NQJ", got)
|
||||||
|
}
|
||||||
|
if got := normalizeCall("PJ2/W2APF", "PJ2"); got != "PJ2/W2APF" {
|
||||||
|
t.Errorf("normalizeCall rewrote an already-correct call: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationStatus(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct{ start, end, want string }{
|
||||||
|
{"Aug 24, 2026", "Aug 31, 2026", "active"},
|
||||||
|
{"Sep 10, 2026", "Sep 20, 2026", "upcoming"},
|
||||||
|
{"Aug 1, 2026", "Aug 10, 2026", "ended"},
|
||||||
|
{"Aug 24, 2026", "Aug 27, 2026", "active"}, // ends TODAY: still on
|
||||||
|
{"garbage", "garbage", "upcoming"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := activationStatus(c.start, c.end, now); got != c.want {
|
||||||
|
t.Errorf("activationStatus(%q,%q) = %q, want %q", c.start, c.end, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mining a headline must find calls without inventing them out of jargon.
|
||||||
|
func TestCallsInHeadline(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
title string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"3B7M, St Brandon", []string{"3B7M"}},
|
||||||
|
{"TX5S team lands on Clipperton", []string{"TX5S"}},
|
||||||
|
{"FT8 activity on 160m in 2026", nil},
|
||||||
|
{"VP6D QSL via OQRS, LoTW", []string{"VP6D"}},
|
||||||
|
{"JD1/JG8NQJ from Minami Torishima", []string{"JD1/JG8NQJ"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := callsInHeadline(c.title); !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Errorf("callsInHeadline(%q) = %v, want %v", c.title, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Club Log's log-matching API. A "match" is a QSO that BOTH stations uploaded
|
||||||
|
// to Club Log, paired within ±15 minutes — Club Log's own equivalent of a LoTW
|
||||||
|
// confirmation. getmatches.php returns a JSON array of 5-element arrays:
|
||||||
|
//
|
||||||
|
// [["G0LGJ/M","223","2005-07-16 08:00:00","20","CW"], …]
|
||||||
|
// callsign dxcc qso datetime (UTC) band mode ("false" when unknown)
|
||||||
|
//
|
||||||
|
// The optional start date filters on when Club Log COMPLETED the match (not
|
||||||
|
// the QSO date), which is exactly what an incremental "since last download"
|
||||||
|
// pull wants.
|
||||||
|
const clublogMatchesURL = "https://clublog.org/getmatches.php"
|
||||||
|
|
||||||
|
// ClublogMatch is one confirmed pairing from getmatches.php.
|
||||||
|
type ClublogMatch struct {
|
||||||
|
Callsign string
|
||||||
|
DXCC int
|
||||||
|
When time.Time
|
||||||
|
Band string // ADIF band ("20m", "70cm"); "" if the id is unknown
|
||||||
|
Mode string // "" when Club Log doesn't know it
|
||||||
|
}
|
||||||
|
|
||||||
|
// clublogBandNames maps Club Log's numeric band ids to ADIF band names. The
|
||||||
|
// ids are the wavelength number; the only trap is that ids past the metre
|
||||||
|
// bands are centimetres (the docs' own example: 70 = 70CM).
|
||||||
|
var clublogBandNames = map[string]string{
|
||||||
|
"2200": "2200m", "630": "630m", "160": "160m", "80": "80m", "60": "60m",
|
||||||
|
"40": "40m", "30": "30m", "20": "20m", "17": "17m", "15": "15m",
|
||||||
|
"12": "12m", "10": "10m", "8": "8m", "6": "6m", "5": "5m", "4": "4m",
|
||||||
|
"2": "2m", "70": "70cm", "23": "23cm", "13": "13cm", "9": "9cm", "3": "3cm",
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadClublogMatches pulls the account's log matches for cfg.Callsign,
|
||||||
|
// optionally only those Club Log completed since sinceDate ("2006-01-02").
|
||||||
|
func DownloadClublogMatches(ctx context.Context, client *http.Client, cfg ServiceConfig, sinceDate string) ([]ClublogMatch, error) {
|
||||||
|
email := strings.TrimSpace(cfg.Email)
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
switch {
|
||||||
|
case email == "":
|
||||||
|
return nil, fmt.Errorf("clublog: account email not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return nil, fmt.Errorf("clublog: password not set")
|
||||||
|
case call == "":
|
||||||
|
return nil, fmt.Errorf("clublog: callsign not set")
|
||||||
|
}
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("api", clublogAppAPIKey)
|
||||||
|
v.Set("email", email)
|
||||||
|
v.Set("password", cfg.Password)
|
||||||
|
v.Set("callsign", call)
|
||||||
|
if t, err := time.Parse("2006-01-02", strings.TrimSpace(sinceDate)); err == nil {
|
||||||
|
v.Set("startyear", strconv.Itoa(t.Year()))
|
||||||
|
v.Set("startmonth", strconv.Itoa(int(t.Month())))
|
||||||
|
v.Set("startday", strconv.Itoa(t.Day()))
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogMatchesURL+"?"+v.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
text := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if looksLikeHTML(text) || len(text) > 300 {
|
||||||
|
return nil, fmt.Errorf("clublog: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("clublog: HTTP %d: %s", resp.StatusCode, text)
|
||||||
|
}
|
||||||
|
if looksLikeHTML(text) {
|
||||||
|
return nil, fmt.Errorf("clublog: got a web page instead of matches — check email/password/callsign")
|
||||||
|
}
|
||||||
|
var raw [][]any
|
||||||
|
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("clublog: bad matches JSON: %w", err)
|
||||||
|
}
|
||||||
|
str := func(x any) string {
|
||||||
|
switch t := x.(type) {
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]ClublogMatch, 0, len(raw))
|
||||||
|
for _, rec := range raw {
|
||||||
|
if len(rec) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := ClublogMatch{Callsign: strings.ToUpper(strings.TrimSpace(str(rec[0])))}
|
||||||
|
m.DXCC, _ = strconv.Atoi(str(rec[1]))
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", str(rec[2])); err == nil {
|
||||||
|
m.When = t.UTC()
|
||||||
|
}
|
||||||
|
m.Band = clublogBandNames[strings.TrimSpace(str(rec[3]))]
|
||||||
|
if md := strings.TrimSpace(str(rec[4])); md != "" && !strings.EqualFold(md, "false") {
|
||||||
|
m.Mode = strings.ToUpper(md)
|
||||||
|
}
|
||||||
|
if m.Callsign == "" || m.When.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ const (
|
|||||||
ServiceCloudlog Service = "cloudlog"
|
ServiceCloudlog Service = "cloudlog"
|
||||||
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
|
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
|
||||||
ServiceHamlog Service = "hamlog"
|
ServiceHamlog Service = "hamlog"
|
||||||
|
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
|
||||||
|
// one ADIF record per QSO.
|
||||||
|
ServiceHamQTH Service = "hamqth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -133,6 +136,7 @@ type ExternalServices struct {
|
|||||||
EQSL ServiceConfig `json:"eqsl"`
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
Cloudlog ServiceConfig `json:"cloudlog"`
|
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||||
Hamlog ServiceConfig `json:"hamlog"`
|
Hamlog ServiceConfig `json:"hamlog"`
|
||||||
|
HamQTH ServiceConfig `json:"hamqth"`
|
||||||
|
|
||||||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||||||
// it is deleted locally. Off unless the operator turns it on: neither
|
// it is deleted locally. Off unless the operator turns it on: neither
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -104,7 +105,26 @@ func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, a
|
|||||||
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrHamlogClosed is why nothing is sent to HAMLOG.online any more.
|
||||||
|
//
|
||||||
|
// The site stopped issuing API keys, and the upload API takes nothing else. An
|
||||||
|
// operator without a key cannot obtain one, and one WITH an old key is the
|
||||||
|
// exception this cannot be built around — so the door is closed here rather
|
||||||
|
// than left ajar for a request that can only fail.
|
||||||
|
//
|
||||||
|
// The code stays: their confirmations still arrive as an ADIF FILE (QSL Manager
|
||||||
|
// → HAMLOG.online → Import confirmations), which never needed a key, and the
|
||||||
|
// sent/received state already in operators' logs stays readable, filterable and
|
||||||
|
// bulk-editable.
|
||||||
|
var ErrHamlogClosed = errors.New("hamlog: HAMLOG.online no longer issues API keys, so uploading is not possible — their confirmations can still be imported from a file")
|
||||||
|
|
||||||
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
return UploadResult{}, ErrHamlogClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadHamlogLive is the upload as it was, kept whole against the day keys
|
||||||
|
// come back. Nothing calls it.
|
||||||
|
func uploadHamlogLive(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
key := strings.TrimSpace(cfg.APIKey)
|
key := strings.TrimSpace(cfg.APIKey)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -28,7 +29,7 @@ func TestUploadHamlogRequestShape(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +65,7 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(tc.body))
|
_, _ = w.Write([]byte(tc.body))
|
||||||
}))
|
}))
|
||||||
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
res, err := uploadHamlogLive(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
|
||||||
srv.Close()
|
srv.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: %v", tc.body, err)
|
t.Fatalf("%s: %v", tc.body, err)
|
||||||
@@ -80,8 +81,21 @@ func TestHamlogFailureIsNotSuccess(t *testing.T) {
|
|||||||
|
|
||||||
// Nothing leaves without a key, and the message says where to get one.
|
// Nothing leaves without a key, and the message says where to get one.
|
||||||
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
func TestUploadHamlogNeedsAKey(t *testing.T) {
|
||||||
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>")
|
_, err := uploadHamlogLive(context.Background(), nil, "http://example.invalid", ServiceConfig{}, "<eor>")
|
||||||
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
|
||||||
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The door is shut: HAMLOG.online stopped issuing API keys, so an upload that
|
||||||
|
// could only fail is refused before it is attempted. The request-shape tests
|
||||||
|
// above still cover the code kept against the day keys come back.
|
||||||
|
func TestUploadHamlogIsClosed(t *testing.T) {
|
||||||
|
res, err := UploadHamlog(context.Background(), nil, ServiceConfig{APIKey: "KEY123"}, "<eor>")
|
||||||
|
if !errors.Is(err, ErrHamlogClosed) {
|
||||||
|
t.Fatalf("err = %v, want ErrHamlogClosed", err)
|
||||||
|
}
|
||||||
|
if res.OK {
|
||||||
|
t.Error("a refused upload reported success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HamQTH real-time QSO upload.
|
||||||
|
//
|
||||||
|
// One POST per QSO to qso_realtime.php with the account username/password —
|
||||||
|
// the same credentials the HamQTH callbook lookup uses. The answer is the
|
||||||
|
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
|
||||||
|
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
|
||||||
|
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
|
||||||
|
|
||||||
|
// hamqthFullLogURL takes a WHOLE log as a file. Note "whole": HamQTH's own
|
||||||
|
// documentation says "you always have to upload whole log. HamQTH doesn't
|
||||||
|
// support partial upload" — the file REPLACES what is on the site. That is why
|
||||||
|
// it is not the batch path for a selection, and why the caller must have said
|
||||||
|
// so out loud before we get here.
|
||||||
|
const hamqthFullLogURL = "https://www.hamqth.com/prg_log_upload.php"
|
||||||
|
|
||||||
|
// hamqthMaxUpload is the documented ceiling for one upload.
|
||||||
|
const hamqthMaxUpload = 20 << 20
|
||||||
|
|
||||||
|
// hamqthCompressAbove is where a plain .adi stops being sent as text. Well
|
||||||
|
// under the limit: the multipart envelope and the form fields ride along too.
|
||||||
|
const hamqthCompressAbove = 12 << 20
|
||||||
|
|
||||||
|
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
|
||||||
|
// endpoint that cannot change anything in the log, which is what the settings
|
||||||
|
// Test button must call.
|
||||||
|
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
|
||||||
|
|
||||||
|
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
|
||||||
|
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
switch {
|
||||||
|
case user == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||||
|
}
|
||||||
|
rec := strings.TrimSpace(adifRecord)
|
||||||
|
if rec == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("u", user)
|
||||||
|
form.Set("p", cfg.Password)
|
||||||
|
// c: the logbook callsign when the account holds several; empty means the
|
||||||
|
// account's own call, which is the common case.
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||||
|
form.Set("c", c)
|
||||||
|
}
|
||||||
|
form.Set("adif", rec)
|
||||||
|
form.Set("prg", "OpsLog")
|
||||||
|
form.Set("cmd", "insert")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
return UploadResult{OK: true}, nil
|
||||||
|
case http.StatusBadRequest:
|
||||||
|
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
|
||||||
|
// purposes — the QSO is in the logbook, retrying it forever isn't —
|
||||||
|
// same treatment HRDLog's <insert>0 gets.
|
||||||
|
if strings.Contains(strings.ToLower(msg), "dupl") {
|
||||||
|
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "QSO rejected"
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: msg}, nil
|
||||||
|
case http.StatusForbidden:
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
|
||||||
|
default:
|
||||||
|
if msg != "" && len(msg) < 200 {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHamQTHFullLog replaces the account's log with the given ADIF.
|
||||||
|
//
|
||||||
|
// DESTRUCTIVE by design of the remote API, not by ours: everything on HamQTH
|
||||||
|
// for this callsign that is not in this file stops existing. The caller owns
|
||||||
|
// the confirmation.
|
||||||
|
//
|
||||||
|
// The file goes in the multipart field "f" (HamQTH's own curl example:
|
||||||
|
// curl -F [email protected] -F send_log=OK -F u=… -F p=…). A large log is sent as a
|
||||||
|
// tar.gz — one of the archive formats the site unpacks — because the ceiling is
|
||||||
|
// 20 MB and a six-figure log passes it as plain text.
|
||||||
|
func UploadHamQTHFullLog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifText string) (UploadResult, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
switch {
|
||||||
|
case user == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: username not set")
|
||||||
|
case cfg.Password == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: password not set")
|
||||||
|
case strings.TrimSpace(adifText) == "":
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: nothing to upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte(adifText)
|
||||||
|
name := "opslog.adi"
|
||||||
|
if len(payload) > hamqthCompressAbove {
|
||||||
|
gz, err := tarGzADIF(payload)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: compressing the log: %w", err)
|
||||||
|
}
|
||||||
|
payload, name = gz, "opslog.tar.gz"
|
||||||
|
}
|
||||||
|
if len(payload) > hamqthMaxUpload {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: the log is %d MB compressed, over HamQTH's %d MB limit",
|
||||||
|
len(payload)>>20, hamqthMaxUpload>>20)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("u", user)
|
||||||
|
_ = mw.WriteField("p", cfg.Password)
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
|
||||||
|
_ = mw.WriteField("c", c)
|
||||||
|
}
|
||||||
|
_ = mw.WriteField("send_log", "OK")
|
||||||
|
fw, err := mw.CreateFormFile("f", name)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := fw.Write(payload); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hamqthFullLogURL, &body)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if client == nil {
|
||||||
|
// A whole log is a long POST on a slow uplink.
|
||||||
|
client = &http.Client{Timeout: 10 * time.Minute}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
msg := strings.TrimSpace(string(raw))
|
||||||
|
if looksLikeHTML(msg) {
|
||||||
|
msg = ""
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg != "" && len(msg) < 300 {
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// The site answers in prose, and only its own refusals are worth reading
|
||||||
|
// back: the ADIF itself is validated later, in the background, and any
|
||||||
|
// complaint about it reaches the operator by e-mail rather than here.
|
||||||
|
low := strings.ToLower(msg)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(low, "successfully"):
|
||||||
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
|
case strings.Contains(low, "wrong username"), strings.Contains(low, "password"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case strings.Contains(low, "cannot upload log for this callsign"):
|
||||||
|
return UploadResult{}, fmt.Errorf("hamqth: %s", msg)
|
||||||
|
case msg == "":
|
||||||
|
// HTTP 200 with nothing to say: taken as accepted, and said so.
|
||||||
|
return UploadResult{OK: true, Message: "uploaded (no reply text)"}, nil
|
||||||
|
default:
|
||||||
|
return UploadResult{OK: false, Message: msg}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGzADIF wraps the ADIF as log.adi inside a tar.gz — the archive must carry
|
||||||
|
// a .adi/.adif member for HamQTH to find the log in it.
|
||||||
|
func tarGzADIF(adif []byte) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&out)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "opslog.adi", Mode: 0o644, Size: int64(len(adif)),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(adif); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHamQTH verifies the credentials against the callbook session login —
|
||||||
|
// authenticated, and unable to touch the log.
|
||||||
|
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
user := strings.TrimSpace(cfg.Username)
|
||||||
|
if user == "" || cfg.Password == "" {
|
||||||
|
return "", fmt.Errorf("hamqth: set the username and password first")
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("u", user)
|
||||||
|
q.Set("p", cfg.Password)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||||
|
s := string(body)
|
||||||
|
if strings.Contains(s, "<session_id>") {
|
||||||
|
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
|
||||||
|
}
|
||||||
|
if i := strings.Index(s, "<error>"); i >= 0 {
|
||||||
|
e := s[i+len("<error>"):]
|
||||||
|
if j := strings.Index(e, "</error>"); j >= 0 {
|
||||||
|
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -337,7 +339,36 @@ func fileExists(p string) bool {
|
|||||||
// they were already uploaded OR outside the callsign certificate's date range.
|
// they were already uploaded OR outside the callsign certificate's date range.
|
||||||
// Reporting either as success is how a contact came to be stamped "uploaded"
|
// Reporting either as success is how a contact came to be stamped "uploaded"
|
||||||
// while LoTW had never seen it.
|
// while LoTW had never seen it.
|
||||||
|
// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary
|
||||||
|
// subdivisions are the US county enumeration — "XX,County" with a two-letter
|
||||||
|
// state — and TQSL rejects the whole record over anything else, so a Canadian
|
||||||
|
// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent.
|
||||||
|
// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW.
|
||||||
|
var myCntyRe = regexp.MustCompile(`(?i)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
|
||||||
|
|
||||||
|
func scrubMyCnty(adif string) string {
|
||||||
|
for {
|
||||||
|
loc := myCntyRe.FindStringSubmatchIndex(adif)
|
||||||
|
if loc == nil {
|
||||||
|
return adif
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(adif[loc[2]:loc[3]])
|
||||||
|
end := loc[1] + n
|
||||||
|
if end > len(adif) {
|
||||||
|
end = len(adif)
|
||||||
|
}
|
||||||
|
val := adif[loc[1]:end]
|
||||||
|
if len(val) > 3 && val[2] == ',' {
|
||||||
|
// "XX,..." — the US shape TQSL accepts; leave it for the county hunters.
|
||||||
|
rest := scrubMyCnty(adif[end:])
|
||||||
|
return adif[:end] + rest
|
||||||
|
}
|
||||||
|
adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||||
|
adifRecord = scrubMyCnty(adifRecord)
|
||||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||||
loc := strings.TrimSpace(cfg.StationLocation)
|
loc := strings.TrimSpace(cfg.StationLocation)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TQSL refuses whole records over a MY_CNTY it cannot validate, and its
|
||||||
|
// validation is the US "XX,County" enumeration — so anything else must be
|
||||||
|
// stripped before signing, and the US shape must survive untouched.
|
||||||
|
func TestScrubMyCnty(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
|
||||||
|
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
|
||||||
|
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
|
||||||
|
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||||
|
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
|
||||||
|
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
|
||||||
|
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := scrubMyCnty(c.in); got != c.want {
|
||||||
|
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-14
@@ -84,9 +84,10 @@ type Deps struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
|
hamlogClosedOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxUploadAttempts bounds retries of a transient upload failure.
|
// maxUploadAttempts bounds retries of a transient upload failure.
|
||||||
@@ -140,6 +141,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
cfg.EQSL = cfg.EQSL.normalised()
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||||
cfg.Hamlog = cfg.Hamlog.normalised()
|
cfg.Hamlog = cfg.Hamlog.normalised()
|
||||||
|
cfg.HamQTH = cfg.HamQTH.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
|
|
||||||
// Summary of what is armed, written at startup and on every settings save.
|
// Summary of what is armed, written at startup and on every settings save.
|
||||||
@@ -153,7 +155,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
}{
|
}{
|
||||||
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||||
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||||
{"hamlog", cfg.Hamlog},
|
{"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
|
||||||
} {
|
} {
|
||||||
if s.cfg.AutoUpload {
|
if s.cfg.AutoUpload {
|
||||||
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||||
@@ -229,12 +231,20 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
m.route(ServiceCloudlog, id, c)
|
m.route(ServiceCloudlog, id, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HAMLOG.online — one API key and nothing else to get wrong.
|
// HAMLOG.online is closed to uploads — see ErrHamlogClosed. Said once per
|
||||||
if h := cfg.Hamlog; h.AutoUpload {
|
// session rather than per QSO, because an operator who left the switch on
|
||||||
if h.APIKey == "" {
|
// deserves to know why nothing leaves, and does not deserve it every minute.
|
||||||
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
|
if cfg.Hamlog.AutoUpload {
|
||||||
|
m.hamlogClosedOnce.Do(func() {
|
||||||
|
m.logf("extsvc: %v", ErrHamlogClosed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// HamQTH — the callbook credentials double as the logbook login.
|
||||||
|
if h := cfg.HamQTH; h.AutoUpload {
|
||||||
|
if h.Username == "" || h.Password == "" {
|
||||||
|
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
|
||||||
} else {
|
} else {
|
||||||
m.route(ServiceHamlog, id, h)
|
m.route(ServiceHamQTH, id, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,8 +297,9 @@ func (m *Manager) onCloseServices() []Service {
|
|||||||
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||||
out = append(out, ServiceCloudlog)
|
out = append(out, ServiceCloudlog)
|
||||||
}
|
}
|
||||||
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
|
|
||||||
out = append(out, ServiceHamlog)
|
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
|
||||||
|
out = append(out, ServiceHamQTH)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -336,8 +347,9 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||||
case ServiceCloudlog:
|
case ServiceCloudlog:
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||||
case ServiceHamlog:
|
|
||||||
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
|
case ServiceHamQTH:
|
||||||
|
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return uploaded
|
return uploaded
|
||||||
@@ -577,7 +589,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
switch svc {
|
switch svc {
|
||||||
case ServiceQRZ, ServiceLoTW:
|
case ServiceQRZ, ServiceLoTW:
|
||||||
owner = cfg.ForceStationCallsign
|
owner = cfg.ForceStationCallsign
|
||||||
case ServiceClublog, ServiceHRDLog:
|
case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
|
||||||
owner = cfg.Callsign
|
owner = cfg.Callsign
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
owner = cfg.Username
|
owner = cfg.Username
|
||||||
@@ -669,6 +681,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
|
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
|
||||||
|
case ServiceHamQTH:
|
||||||
|
// The c parameter names the logbook when the account holds several;
|
||||||
|
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
|
||||||
default:
|
default:
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package geo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,3 +119,65 @@ func NeighbourGrids(lat, lon float64, ring int) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GridsWithin returns every Maidenhead square whose centre lies within km of
|
||||||
|
// (lat, lon), nearest first, at most max of them.
|
||||||
|
//
|
||||||
|
// NeighbourGrids answers "the ring around here", which is the right shape for a
|
||||||
|
// few hundred kilometres and the wrong one past that: a ring is a square, so
|
||||||
|
// asking for 2000 km through it means 1369 squares, most of them further away
|
||||||
|
// than the ones it left out. This measures instead, and the count then follows
|
||||||
|
// the AREA asked for rather than the corner of a box.
|
||||||
|
//
|
||||||
|
// Nearest first because the caller has to be able to trim: these become one
|
||||||
|
// broker subscription each, and when there are more than can be afforded, the
|
||||||
|
// squares to keep are the close ones.
|
||||||
|
func GridsWithin(lat, lon, km float64, max int) []string {
|
||||||
|
if km <= 0 || max <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// One square is 1° of latitude and 2° of longitude. Sweep a box big enough
|
||||||
|
// to hold the circle — a degree of latitude is ~111 km everywhere, and a
|
||||||
|
// degree of longitude never MORE than that, so this cannot cut the circle.
|
||||||
|
steps := int(km/111.0) + 1
|
||||||
|
type cand struct {
|
||||||
|
grid string
|
||||||
|
d float64
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []cand{}
|
||||||
|
for dLat := -steps; dLat <= steps; dLat++ {
|
||||||
|
for dLon := -2 * steps; dLon <= 2*steps; dLon++ {
|
||||||
|
la := lat + float64(dLat)
|
||||||
|
lo := lon + float64(dLon)*2
|
||||||
|
if la > 90 || la < -90 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := LatLonToGrid(la, lo)
|
||||||
|
if seen[g] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Measured to the square's own centre, not to the sample point, so
|
||||||
|
// two samples landing in one square agree about how far it is.
|
||||||
|
cLat, cLon, ok := GridToLatLon(g)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := HaversineKm(lat, lon, cLat, cLon)
|
||||||
|
if d > km {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[g] = true
|
||||||
|
out = append(out, cand{g, d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].d < out[j].d })
|
||||||
|
if len(out) > max {
|
||||||
|
out = out[:max]
|
||||||
|
}
|
||||||
|
grids := make([]string, len(out))
|
||||||
|
for i, c := range out {
|
||||||
|
grids[i] = c.grid
|
||||||
|
}
|
||||||
|
return grids
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,3 +113,27 @@ func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
|
|||||||
ev.DecodeModeRaw, "~")
|
ev.DecodeModeRaw, "~")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reported from a real shack: JTDX decoding FT4 showed up as Q65.
|
||||||
|
//
|
||||||
|
// The two forks disagree about ":" — Q65 in WSJT-X, FT4 in JTDX — so the
|
||||||
|
// character alone cannot answer, and the wrong answer poisons every verdict
|
||||||
|
// that follows: new mode, new slot, the mode filter. The program's own Status
|
||||||
|
// names the mode in full and settles it.
|
||||||
|
func TestAmbiguousModeCharDefersToTheProgram(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw, status, want, why string
|
||||||
|
}{
|
||||||
|
{":", "FT4", "FT4", "JTDX decoding FT4"},
|
||||||
|
{":", "Q65", "Q65", "WSJT-X decoding Q65"},
|
||||||
|
{":", "", "Q65", "no Status yet — WSJT-X's reading, the older and commoner"},
|
||||||
|
// The unambiguous markers are unaffected, Status or no Status.
|
||||||
|
{"~", "FT4", "FT8", "a tilde is FT8 in both"},
|
||||||
|
{"+", "", "FT4", "a plus is FT4 in both"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := DecodeModeName(c.raw, c.status); got != c.want {
|
||||||
|
t.Errorf("DecodeModeName(%q, %q) = %q, want %q — %s", c.raw, c.status, got, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ type Event struct {
|
|||||||
DecodeModeRaw string
|
DecodeModeRaw string
|
||||||
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
|
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
|
||||||
DecodeMsgRaw string
|
DecodeMsgRaw string
|
||||||
|
// DecodeIsNew is false on the history a Replay resends: display-only lines.
|
||||||
|
DecodeIsNew bool
|
||||||
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
|
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
|
||||||
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
|
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
|
||||||
// tells two receivers apart on one multicast group — and it is the address a
|
// tells two receivers apart on one multicast group — and it is the address a
|
||||||
@@ -212,6 +214,9 @@ type Server struct {
|
|||||||
// lastFrom is the address each program's packets arrive from — where a Reply
|
// lastFrom is the address each program's packets arrive from — where a Reply
|
||||||
// has to be sent. See SendReply.
|
// has to be sent. See SendReply.
|
||||||
lastFrom map[string]*net.UDPAddr
|
lastFrom map[string]*net.UDPAddr
|
||||||
|
// onNewInstance fires (off the read loop) the first time a program id is
|
||||||
|
// heard on this listener — the hook the startup replay hangs from.
|
||||||
|
onNewInstance func(programID string)
|
||||||
// instLabel names each running application, keyed by id AND sending address.
|
// instLabel names each running application, keyed by id AND sending address.
|
||||||
//
|
//
|
||||||
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
|
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
|
||||||
@@ -285,11 +290,12 @@ func describePacket(pkt []byte) string {
|
|||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
out: out,
|
out: out,
|
||||||
mgr: mgr,
|
mgr: mgr,
|
||||||
stop: make(chan struct{}),
|
onNewInstance: mgr.onNewInstance,
|
||||||
done: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,13 +521,23 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// must go to the sender's own address, never to the group.
|
// must go to the sender's own address, never to the group.
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
inst := s.instanceLabel(w.ProgramID, remote)
|
inst := s.instanceLabel(w.ProgramID, remote)
|
||||||
|
newInstance := false
|
||||||
if inst != "" && remote != nil {
|
if inst != "" && remote != nil {
|
||||||
if s.lastFrom == nil {
|
if s.lastFrom == nil {
|
||||||
s.lastFrom = map[string]*net.UDPAddr{}
|
s.lastFrom = map[string]*net.UDPAddr{}
|
||||||
}
|
}
|
||||||
|
if _, known := s.lastFrom[inst]; !known {
|
||||||
|
newInstance = true
|
||||||
|
}
|
||||||
s.lastFrom[inst] = remote
|
s.lastFrom[inst] = remote
|
||||||
}
|
}
|
||||||
|
onNew := s.onNewInstance
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
// A program just heard for the first time this session: tell the app, so
|
||||||
|
// it can ask for a replay of the decodes already on that program's screen.
|
||||||
|
if newInstance && onNew != nil {
|
||||||
|
go onNew(inst)
|
||||||
|
}
|
||||||
// Status carries the current dial frequency; remember it so Decode audio
|
// Status carries the current dial frequency; remember it so Decode audio
|
||||||
// offsets can be turned into RF frequencies for the panadapter.
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
if w.FreqHz > 0 && !w.IsDecode {
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
@@ -580,6 +596,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.DecodeModeRaw = w.Mode
|
ev.DecodeModeRaw = w.Mode
|
||||||
ev.DecodeMsg = w.DecodeMsg
|
ev.DecodeMsg = w.DecodeMsg
|
||||||
ev.DecodeMsgRaw = w.DecodeMsgRaw
|
ev.DecodeMsgRaw = w.DecodeMsgRaw
|
||||||
|
ev.DecodeIsNew = w.DecodeIsNew
|
||||||
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
|
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
|
||||||
ev.DecodeTRPeriod = tr
|
ev.DecodeTRPeriod = tr
|
||||||
ev.DecodeDial = dial
|
ev.DecodeDial = dial
|
||||||
@@ -803,6 +820,10 @@ type Manager struct {
|
|||||||
repo *Repo
|
repo *Repo
|
||||||
out chan Event
|
out chan Event
|
||||||
|
|
||||||
|
// onNewInstance is copied onto every inbound listener as it starts; see
|
||||||
|
// Server.onNewInstance.
|
||||||
|
onNewInstance func(programID string)
|
||||||
|
|
||||||
// noADIFOnce keeps the "nothing to forward to" note to one line a session
|
// noADIFOnce keeps the "nothing to forward to" note to one line a session
|
||||||
// rather than one per QSO logged.
|
// rather than one per QSO logged.
|
||||||
noADIFOnce sync.Once
|
noADIFOnce sync.Once
|
||||||
@@ -940,3 +961,11 @@ func (m *Manager) StopAll() {
|
|||||||
s.close()
|
s.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnNewInstance installs the first-sighting hook. Call before Reload so
|
||||||
|
// listeners are born with it.
|
||||||
|
func (m *Manager) SetOnNewInstance(fn func(programID string)) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.onNewInstance = fn
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|||||||
@@ -523,10 +523,24 @@ var decodeModeChar = map[string]string{
|
|||||||
"#": "JT65",
|
"#": "JT65",
|
||||||
"@": "JT9",
|
"@": "JT9",
|
||||||
"&": "MSK144",
|
"&": "MSK144",
|
||||||
":": "Q65",
|
|
||||||
"`": "FST4",
|
"`": "FST4",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ambiguousModeChar is a marker the forks do not agree on.
|
||||||
|
//
|
||||||
|
// ":" is Q65 in WSJT-X and FT4 in JTDX, so the character alone cannot answer:
|
||||||
|
// a JTDX operator decoding FT4 was told they were on Q65, and every verdict
|
||||||
|
// downstream — new mode, new slot, the mode filter — was computed against a
|
||||||
|
// mode nobody was using.
|
||||||
|
//
|
||||||
|
// The sender's own Status settles it. It comes from the same program, names the
|
||||||
|
// mode in full, and is re-sent whenever it changes, so it knows what that
|
||||||
|
// program is decoding in a way one character never can. The fallback is
|
||||||
|
// WSJT-X's reading, which is the older and commoner one.
|
||||||
|
var ambiguousModeChar = map[string]string{
|
||||||
|
":": "Q65",
|
||||||
|
}
|
||||||
|
|
||||||
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
|
// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode
|
||||||
// is the mode from the same program's last Status, used when the field is a
|
// is the mode from the same program's last Status, used when the field is a
|
||||||
// marker we do not know, or empty.
|
// marker we do not know, or empty.
|
||||||
@@ -535,6 +549,13 @@ func DecodeModeName(raw, statusMode string) string {
|
|||||||
if m, ok := decodeModeChar[raw]; ok {
|
if m, ok := decodeModeChar[raw]; ok {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
if fallback, ok := ambiguousModeChar[raw]; ok {
|
||||||
|
// Believe the program over the character it happened to print.
|
||||||
|
if st := strings.ToUpper(strings.TrimSpace(statusMode)); st != "" {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
|
// A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65").
|
||||||
// Anything shorter, or carrying punctuation, is a marker rather than a name.
|
// Anything shorter, or carrying punctuation, is a marker rather than a name.
|
||||||
if len(raw) >= 2 {
|
if len(raw) >= 2 {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSJT-X Configure (message 15) — change the decoder's settings remotely. Used
|
||||||
|
// for ONE thing here: clicking an FT4 spot while the decoder sits in FT8
|
||||||
|
// switches its mode too, so the operator lands ready to decode instead of
|
||||||
|
// staring at a band of gibberish. Every other field is sent as "no change"
|
||||||
|
// (empty strings, max-quint32), per the protocol.
|
||||||
|
const wsjtMsgConfigure = 15
|
||||||
|
|
||||||
|
// EncodeConfigureMode builds a Configure datagram that changes only the mode.
|
||||||
|
func EncodeConfigureMode(programID, mode string) []byte {
|
||||||
|
const noChange32 = ^uint32(0)
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(2))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgConfigure))
|
||||||
|
writeQString(&b, programID)
|
||||||
|
writeQString(&b, mode) // Mode
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, noChange32) // Frequency Tolerance — no change
|
||||||
|
writeQString(&b, "") // Submode — no change
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Fast Mode — off (right for every HF mode)
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, noChange32) // T/R Period — no change
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, noChange32) // Rx DF — no change
|
||||||
|
writeQString(&b, "") // DX Call — no change
|
||||||
|
writeQString(&b, "") // DX Grid — no change
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Generate Messages — no
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendConfigureMode asks every decoder heard this session to switch mode.
|
||||||
|
// Sent to all instances rather than one: the spot click does not say which
|
||||||
|
// decoder the operator is looking at, and a second instance already in the
|
||||||
|
// right mode treats the message as a no-op.
|
||||||
|
func (m *Manager) SendConfigureMode(mode string) {
|
||||||
|
for _, inst := range m.Instances() {
|
||||||
|
if err := m.sendToInstance(inst, EncodeConfigureMode(inst, mode), "configure-mode"); err == nil {
|
||||||
|
applog.Printf("udp: asked %q to switch to %s", inst, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSJT-X Highlight Callsign (13) and Replay (7) — the two halves of making the
|
||||||
|
// Band Activity window log-aware.
|
||||||
|
//
|
||||||
|
// Highlight paints a callsign in the decoding application's own window with the
|
||||||
|
// colours OpsLog chooses — new DXCC, new band, a watchlist member — the way
|
||||||
|
// JTAlert does. Replay asks a freshly-discovered instance to resend the decodes
|
||||||
|
// it already has on screen, so the FT decodes panel starts full instead of
|
||||||
|
// empty until the next period.
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsjtMsgReplay = 7
|
||||||
|
wsjtMsgHighlight = 13
|
||||||
|
)
|
||||||
|
|
||||||
|
// RGB is one highlight colour. A nil *RGB means "invalid QColor", which is the
|
||||||
|
// protocol's way of saying "remove the highlight".
|
||||||
|
type RGB struct{ R, G, B uint8 }
|
||||||
|
|
||||||
|
// writeQColor serializes a QColor as QDataStream does: a spec byte (1 = RGB,
|
||||||
|
// 0 = invalid) followed by five 16-bit channels (alpha, red, green, blue, pad),
|
||||||
|
// each 8-bit value doubled into 16 bits the way Qt stores them.
|
||||||
|
func writeQColor(b *bytes.Buffer, c *RGB) {
|
||||||
|
if c == nil {
|
||||||
|
b.WriteByte(0) // invalid — clears the highlight
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = binary.Write(b, binary.BigEndian, uint16(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteByte(1) // spec = RGB
|
||||||
|
wide := func(v uint8) uint16 { return uint16(v) * 0x101 }
|
||||||
|
_ = binary.Write(b, binary.BigEndian, uint16(0xFFFF)) // alpha, opaque
|
||||||
|
_ = binary.Write(b, binary.BigEndian, wide(c.R))
|
||||||
|
_ = binary.Write(b, binary.BigEndian, wide(c.G))
|
||||||
|
_ = binary.Write(b, binary.BigEndian, wide(c.B))
|
||||||
|
_ = binary.Write(b, binary.BigEndian, uint16(0)) // pad
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeHighlight builds a Highlight Callsign datagram. bg/fg nil = invalid
|
||||||
|
// colour; both nil clears the callsign's highlight.
|
||||||
|
func EncodeHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(2))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHighlight))
|
||||||
|
writeQString(&b, programID)
|
||||||
|
writeQString(&b, callsign)
|
||||||
|
writeQColor(&b, bg)
|
||||||
|
writeQColor(&b, fg)
|
||||||
|
var last uint8
|
||||||
|
if lastPeriodOnly {
|
||||||
|
last = 1
|
||||||
|
}
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, last)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeReplay builds a Replay datagram — "resend what your window holds".
|
||||||
|
func EncodeReplay(programID string) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(2))
|
||||||
|
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReplay))
|
||||||
|
writeQString(&b, programID)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendToInstance routes a raw datagram to the application that owns programID,
|
||||||
|
// the same way SendReply does: to the address its packets actually arrive from.
|
||||||
|
func (m *Manager) sendToInstance(programID string, pkt []byte, what string) error {
|
||||||
|
if strings.TrimSpace(programID) == "" {
|
||||||
|
return fmt.Errorf("no application id")
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
servers := make([]*Server, 0, len(m.inbound))
|
||||||
|
for _, s := range m.inbound {
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
for _, s := range servers {
|
||||||
|
conn, addr := s.replyTarget(programID)
|
||||||
|
if conn == nil || addr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
|
||||||
|
return fmt.Errorf("send %s to %s at %s: %w", what, programID, addr, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no packet has arrived from %q yet", programID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendHighlight paints (or clears) one callsign in the given instance.
|
||||||
|
func (m *Manager) SendHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) error {
|
||||||
|
return m.sendToInstance(programID, EncodeHighlight(programID, callsign, bg, fg, lastPeriodOnly), "highlight")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendClearHighlights removes every highlighting instruction OpsLog installed
|
||||||
|
// in the instance. "CLEARALL!" is the protocol's own magic callsign for it.
|
||||||
|
func (m *Manager) SendClearHighlights(programID string) error {
|
||||||
|
return m.sendToInstance(programID, EncodeHighlight(programID, "CLEARALL!", nil, nil, false), "clear-highlights")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendReplay asks the instance to resend its on-screen decodes.
|
||||||
|
func (m *Manager) SendReplay(programID string) error {
|
||||||
|
err := m.sendToInstance(programID, EncodeReplay(programID), "replay")
|
||||||
|
if err == nil {
|
||||||
|
applog.Printf("udp: replay requested from %q — its existing decodes will arrive marked not-new", programID)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instances lists every program id a packet has arrived from, for "clear the
|
||||||
|
// highlights everywhere" and the startup replay.
|
||||||
|
func (m *Manager) Instances() []string {
|
||||||
|
m.mu.Lock()
|
||||||
|
servers := make([]*Server, 0, len(m.inbound))
|
||||||
|
for _, s := range m.inbound {
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, s := range servers {
|
||||||
|
s.mu.Lock()
|
||||||
|
for id := range s.lastFrom {
|
||||||
|
if _, dup := seen[id]; !dup {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+27
-4
@@ -79,9 +79,10 @@ type Config struct {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
|
|
||||||
mu sync.Mutex // serialises the connection: one question at a time
|
mu sync.Mutex // serialises the connection: one question at a time
|
||||||
conn io.ReadWriteCloser
|
conn io.ReadWriteCloser
|
||||||
rd *bufio.Reader
|
rd *bufio.Reader
|
||||||
|
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
|
||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
|
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
|
||||||
}
|
}
|
||||||
|
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
|
||||||
|
// switches the amplifier on by raising them. Held asserted, once, and
|
||||||
|
// never touched again — reconnect cycles that toggled them were
|
||||||
|
// switching a KPA500 OFF twenty seconds after its operator pressed
|
||||||
|
// nothing but Standby.
|
||||||
|
_ = p.SetDTR(true)
|
||||||
|
_ = p.SetRTS(true)
|
||||||
_ = p.SetReadTimeout(ioTimeout)
|
_ = p.SetReadTimeout(ioTimeout)
|
||||||
c.conn = p
|
c.conn = p
|
||||||
}
|
}
|
||||||
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
|
|||||||
// the frame.
|
// the frame.
|
||||||
line, err := c.rd.ReadString(';')
|
line, err := c.rd.ReadString(';')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.dropLocked()
|
// NOT dropped. A command this model simply does not know (^TP is the
|
||||||
|
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
|
||||||
|
// link, and dropping here tore the connection down on every slow poll
|
||||||
|
// cycle: two seconds of stalled commands, a reconnect, and a DTR
|
||||||
|
// toggle the amplifier read as the off switch. Nothing arrived, so
|
||||||
|
// nothing is left to desynchronise the next exchange. Write errors —
|
||||||
|
// the genuinely dead link — still drop, above.
|
||||||
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
|
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(line), nil
|
return strings.TrimSpace(line), nil
|
||||||
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
|
|||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.skipTP {
|
||||||
|
return
|
||||||
|
}
|
||||||
if reply, err := c.ask("^TP;"); err == nil {
|
if reply, err := c.ask("^TP;"); err == nil {
|
||||||
if v, err := parseInt(reply, "^TP"); err == nil {
|
if v, err := parseInt(reply, "^TP"); err == nil {
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Tuning = v == 1
|
c.status.Tuning = v == 1
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// One silence is the model's answer for good: a KPA500 has no ATU and
|
||||||
|
// will never answer ^TP — asking again every cycle cost a two-second
|
||||||
|
// stall each time.
|
||||||
|
c.skipTP = true
|
||||||
|
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,9 +167,14 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
r.Callsign = call
|
r.Callsign = call
|
||||||
r.Source = p.Name()
|
r.Source = p.Name()
|
||||||
r.FetchedAt = time.Now().UTC()
|
r.FetchedAt = time.Now().UTC()
|
||||||
fillFromDXCC(&r, dxcc)
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
|
// Cached BEFORE the cty.dat pass, so the row is a copy of the
|
||||||
|
// callbook page rather than of our conclusions about it. Every
|
||||||
|
// read runs the pass again (see the cache-hit path above), so a
|
||||||
|
// later cty.dat update reaches old rows — and a value we derived
|
||||||
|
// can never come back looking like something the page said.
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r)
|
||||||
|
fillFromDXCC(&r, dxcc)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
@@ -208,9 +213,9 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
if !saysNothingAboutLocation(call) {
|
if !saysNothingAboutLocation(call) {
|
||||||
clearHomeLocation(&r)
|
clearHomeLocation(&r)
|
||||||
}
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
_ = m.cache.Put(ctx, r)
|
_ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
|
||||||
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,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
|
||||||
@@ -425,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,11 +499,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
r.Continent = cont
|
r.Continent = cont
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if cqz != 0 {
|
// Zones FILL, they do not override.
|
||||||
|
//
|
||||||
|
// The rule above is right for the country and wrong for the zones, because
|
||||||
|
// they answer different questions. An entity is what a callsign IS, and
|
||||||
|
// cty.dat is the authority on that. A zone is where the station SITS, and a
|
||||||
|
// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
|
||||||
|
// and cty.dat carries one representative pair for the whole country. Stamping
|
||||||
|
// it on every UA0 threw away the callbook's per-station answer and recorded a
|
||||||
|
// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
|
||||||
|
// and was logged 17/30.
|
||||||
|
//
|
||||||
|
// So the callbook wins where it spoke, and cty.dat fills the silence.
|
||||||
|
if cqz != 0 && r.CQZ == 0 {
|
||||||
r.CQZ = cqz
|
r.CQZ = cqz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
if ituz != 0 {
|
if ituz != 0 && r.ITUZ == 0 {
|
||||||
r.ITUZ = ituz
|
r.ITUZ = ituz
|
||||||
filled = true
|
filled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// asiaticRussia stands in for cty.dat: one representative zone pair for a
|
||||||
|
// country eight CQ zones wide.
|
||||||
|
func asiaticRussia(cqz, ituz int) fakeDXCC {
|
||||||
|
return fakeDXCC{"RU0LL": {num: 15, country: "Asiatic Russia", cont: "AS", cqz: cqz, ituz: ituz}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zone is where the station SITS; an entity is what the callsign IS. Asiatic
|
||||||
|
// Russia spans CQ 16-23 and ITU 20-34, and cty.dat carries one representative
|
||||||
|
// pair for the whole country — so stamping it over a callbook's per-station
|
||||||
|
// answer records a WAZ credit for a zone the operator never worked.
|
||||||
|
//
|
||||||
|
// Reported with real callsigns: RU0LL is CQ 19 / ITU 34 and was logged 17/30.
|
||||||
|
func TestCallbookZonesSurviveTheEntityDefault(t *testing.T) {
|
||||||
|
// What QRZ said about this very station.
|
||||||
|
r := Result{Callsign: "RU0LL", CQZ: 19, ITUZ: 34}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 19 || r.ITUZ != 34 {
|
||||||
|
t.Errorf("callbook zones were overwritten: CQ%d ITU%d, want CQ19 ITU34", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
if r.Country != "Asiatic Russia" {
|
||||||
|
t.Errorf("the ENTITY must still come from cty.dat, got %q", r.Country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityZonesFillSilence(t *testing.T) {
|
||||||
|
// A callbook that says nothing about zones still gets an answer.
|
||||||
|
r := Result{Callsign: "RU0LL"}
|
||||||
|
fillFromDXCC(&r, asiaticRussia(17, 30))
|
||||||
|
if r.CQZ != 17 || r.ITUZ != 30 {
|
||||||
|
t.Errorf("empty zones were not filled: CQ%d ITU%d", r.CQZ, r.ITUZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-5
@@ -140,8 +140,17 @@ func New(cfg Config) *Watcher {
|
|||||||
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
||||||
// feed is actually about, and it is the same for every operator.
|
// feed is actually about, and it is the same for every operator.
|
||||||
func (w *Watcher) topics() []string {
|
func (w *Watcher) topics() []string {
|
||||||
|
bands := w.cfg.Bands
|
||||||
|
// One subscription per band per square multiplies, and past a point the
|
||||||
|
// cheaper trade is to take every band from those squares and drop the
|
||||||
|
// unwanted ones here: four bands over six hundred squares is 2400
|
||||||
|
// subscriptions, where "+" is 600 for maybe three times the messages —
|
||||||
|
// which are then filtered locally, as they already are for everything else.
|
||||||
|
if len(bands) > 1 && len(bands)*len(w.cfg.RxGrids) > 1000 {
|
||||||
|
bands = []string{"+"}
|
||||||
|
}
|
||||||
out := []string{}
|
out := []string{}
|
||||||
for _, b := range w.cfg.Bands {
|
for _, b := range bands {
|
||||||
if len(w.cfg.RxGrids) == 0 {
|
if len(w.cfg.RxGrids) == 0 {
|
||||||
out = append(out, "pskr/filter/v2/"+b+"/#")
|
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||||
continue
|
continue
|
||||||
@@ -181,13 +190,30 @@ func (w *Watcher) Start() error {
|
|||||||
|
|
||||||
opts.OnConnect = func(c mqtt.Client) {
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||||
for _, topic := range w.topics() {
|
topics := w.topics()
|
||||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
// In batches, not one at a time. Each Subscribe waits for its own
|
||||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
// acknowledgement, which is fine for the nine squares this started with
|
||||||
|
// and is minutes of waiting for the six hundred a 2000 km radius asks
|
||||||
|
// for — during which the feed is only partly subscribed and the panel
|
||||||
|
// looks broken.
|
||||||
|
const batch = 100
|
||||||
|
subbed := 0
|
||||||
|
for i := 0; i < len(topics); i += batch {
|
||||||
|
end := i + batch
|
||||||
|
if end > len(topics) {
|
||||||
|
end = len(topics)
|
||||||
|
}
|
||||||
|
filters := make(map[string]byte, end-i)
|
||||||
|
for _, t := range topics[i:end] {
|
||||||
|
filters[t] = 0
|
||||||
|
}
|
||||||
|
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
|
||||||
|
w.cfg.Logf("pskr: subscribing to %d topics failed: %v", len(filters), tok.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w.cfg.Logf("pskr: watching %s", topic)
|
subbed += len(filters)
|
||||||
}
|
}
|
||||||
|
w.cfg.Logf("pskr: watching %d topics (%d bands × %d receiver squares)", subbed, len(w.cfg.Bands), max(len(w.cfg.RxGrids), 1))
|
||||||
}
|
}
|
||||||
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -293,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 {
|
||||||
@@ -301,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
|
||||||
|
|||||||
@@ -0,0 +1,918 @@
|
|||||||
|
// Package pskrtgt answers one question about one station: can they hear me?
|
||||||
|
//
|
||||||
|
// It is the other way round from internal/pskr. That watcher asks what is
|
||||||
|
// happening AROUND HERE — reports collected near the operator, whoever sent
|
||||||
|
// them — and it is the right shape for finding a band opening or a new entity.
|
||||||
|
// This one starts from a callsign the operator wants to work and gathers the
|
||||||
|
// evidence about that path, in both directions:
|
||||||
|
//
|
||||||
|
// - did the DX decode MY call, and how long ago
|
||||||
|
// - who NEAR ME did the DX decode (the path is open at my end)
|
||||||
|
// - who near the DX decoded ME (the path is open at his end, even when he
|
||||||
|
// uploads nothing himself)
|
||||||
|
// - how many stations he is decoding right now (the pileup I am up against)
|
||||||
|
// - where in his receive passband those decodes land, so a caller can pick a
|
||||||
|
// slot he is not already covered on
|
||||||
|
//
|
||||||
|
// Nothing here is persisted and nothing is inferred from a QSO: it is a sliding
|
||||||
|
// window of PSK Reporter reports, and when the window empties the answer goes
|
||||||
|
// back to "not known", which is the honest answer.
|
||||||
|
//
|
||||||
|
// The window is FIVE minutes. An FT8 cycle is fifteen seconds, so that is
|
||||||
|
// twenty chances for a path to show itself — short enough that "he decoded you"
|
||||||
|
// still means now, long enough that one missed cycle does not erase it.
|
||||||
|
package pskrtgt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS. Same one the
|
||||||
|
// band-opening watcher uses — two connections to it, because the two want
|
||||||
|
// opposite slices of the feed and neither can be filtered out of the other's.
|
||||||
|
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// window is how far back a report still counts.
|
||||||
|
//
|
||||||
|
// TEN minutes. Five was chosen as "recent enough to still mean now", and on
|
||||||
|
// the air it meant half the evidence: PSK Reporter's uploaders batch their
|
||||||
|
// reports, many of them every five minutes, so a five-minute window catches
|
||||||
|
// roughly one upload cycle per station. Side by side with DXHunter on the
|
||||||
|
// same DX, the same second: 18 decodes here against 27 there, and a station
|
||||||
|
// missing from "from your area" that was simply six minutes old.
|
||||||
|
//
|
||||||
|
// It is a window on ONE station's activity, not on the band: ten minutes of
|
||||||
|
// a DX working a pileup is still what he is doing now.
|
||||||
|
window = 10 * time.Minute
|
||||||
|
// pileupWindow is the tighter one for "how many stations is he working
|
||||||
|
// through". A station he decoded four minutes ago has very likely moved on,
|
||||||
|
// and counting it inflates the only number an operator uses to decide
|
||||||
|
// whether it is worth calling at all.
|
||||||
|
pileupWindow = 2 * time.Minute
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// there is essentially no FT8, and drawing the empty space made the strip
|
||||||
|
// look broken rather than empty.
|
||||||
|
binHz = 60
|
||||||
|
lowHz = 200
|
||||||
|
highHz = 4000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope decides how much of the feed is subscribed to.
|
||||||
|
type Scope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ScopeTarget subscribes to three filters: what the DX transmits, what he
|
||||||
|
// receives, and who hears the operator. A handful of messages a second, and
|
||||||
|
// the REST backfill fills the window the moment the target changes.
|
||||||
|
ScopeTarget Scope = "target"
|
||||||
|
// ScopeBand subscribes to the whole band's FTx traffic. Switching target is
|
||||||
|
// then instant with no backfill, at the cost of every message on the band —
|
||||||
|
// hundreds a second when 20 m is busy.
|
||||||
|
ScopeBand Scope = "band"
|
||||||
|
)
|
||||||
|
|
||||||
|
// spot is one PSK Reporter reception report, as the v2 payload carries it.
|
||||||
|
type spot struct {
|
||||||
|
Freq int64 `json:"f"`
|
||||||
|
Mode string `json:"md"`
|
||||||
|
SNR int `json:"rp"`
|
||||||
|
TxCall string `json:"sc"`
|
||||||
|
TxGrid string `json:"sl"`
|
||||||
|
RxCall string `json:"rc"`
|
||||||
|
RxGrid string `json:"rl"`
|
||||||
|
Band string `json:"b"`
|
||||||
|
// at is stamped on arrival. The payload's own timestamps differ between
|
||||||
|
// versions of the feed, and everything here is measured in minutes.
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is one station in one of the lists the panel shows.
|
||||||
|
type Entry struct {
|
||||||
|
Call string `json:"call"`
|
||||||
|
Grid string `json:"grid"`
|
||||||
|
SNR int `json:"snr"`
|
||||||
|
OffsetHz int `json:"offset_hz"` // audio offset from the operator's dial
|
||||||
|
AgeSec int `json:"age_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bin is one 60 Hz slice of the DX's receive passband.
|
||||||
|
type Bin struct {
|
||||||
|
OffsetHz int `json:"offset_hz"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
AvgSNR float64 `json:"avg_snr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis is the whole snapshot the panel draws, recomputed on demand.
|
||||||
|
type Analysis struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
// Enabled is the operator's switch; Online is whether the broker is
|
||||||
|
// actually connected. A panel that says nothing has to be able to say WHY.
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
// Spots is everything in the window, the sign that the feed is alive even
|
||||||
|
// when every counter below is legitimately zero.
|
||||||
|
Spots int `json:"spots"`
|
||||||
|
|
||||||
|
// HeMe is the answer to the question. The rest is what to do when it is no.
|
||||||
|
HeMe bool `json:"he_me"`
|
||||||
|
HeMeSeconds int `json:"he_me_seconds"`
|
||||||
|
HeMeSNR int `json:"he_me_snr"`
|
||||||
|
HeMeOffset int `json:"he_me_offset_hz"`
|
||||||
|
|
||||||
|
// TargetUploads distinguishes "he is not hearing anybody" from "his software
|
||||||
|
// tells PSK Reporter nothing" — without it, a silent panel reads as a dead
|
||||||
|
// band when it may be a full one.
|
||||||
|
TargetUploads bool `json:"target_uploads"`
|
||||||
|
TargetGrid string `json:"target_grid,omitempty"`
|
||||||
|
|
||||||
|
// Near the DX: stations in his square that decoded the operator. This is
|
||||||
|
// what still works when he uploads nothing himself.
|
||||||
|
NearHimCount int `json:"near_him_count"`
|
||||||
|
NearHimTop []Entry `json:"near_him_top"`
|
||||||
|
|
||||||
|
// Near the operator: stations in his own field that the DX decoded.
|
||||||
|
FromMyAreaCount int `json:"from_my_area_count"`
|
||||||
|
FromMyAreaTop []Entry `json:"from_my_area_top"`
|
||||||
|
PathOpen bool `json:"path_open"`
|
||||||
|
|
||||||
|
// Who heard the DX, worldwide and locally.
|
||||||
|
HeardByCount int `json:"heard_by_count"`
|
||||||
|
HeardNearMe int `json:"heard_near_me"`
|
||||||
|
HeardNearMeTop []Entry `json:"heard_near_me_top"`
|
||||||
|
|
||||||
|
// The pileup: everyone he decoded (window), and the recent slice of it.
|
||||||
|
DecodedByCount int `json:"decoded_by_count"`
|
||||||
|
DecodedByTop []Entry `json:"decoded_by_top"`
|
||||||
|
DecodedByCalls []string `json:"decoded_by_calls"`
|
||||||
|
PileupCount int `json:"pileup_count"`
|
||||||
|
|
||||||
|
// His receive passband, and a slot in it that nobody is using.
|
||||||
|
DialHz int64 `json:"dial_hz"`
|
||||||
|
CeilingHz int `json:"ceiling_hz"`
|
||||||
|
DecodesInWindow int `json:"decodes_in_window"`
|
||||||
|
Bins []Bin `json:"bins"`
|
||||||
|
SuggestedOffset int `json:"suggested_offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what the watcher needs from the application.
|
||||||
|
type Config struct {
|
||||||
|
Broker string
|
||||||
|
Scope Scope
|
||||||
|
// MyCall and MyGrid are the operator's. Both matter: the callsign is what
|
||||||
|
// "he decoded you" is looked up by, and the grid decides what counts as
|
||||||
|
// "near me" — its first two characters, a Maidenhead FIELD, which is a few
|
||||||
|
// hundred kilometres rather than a whole continent.
|
||||||
|
MyCall string
|
||||||
|
MyGrid string
|
||||||
|
// Continent resolves a callsign to EU/NA/AS/… It is only a FALLBACK, for an
|
||||||
|
// operator whose grid is not set: without a grid there is nothing to compare
|
||||||
|
// squares with, and a continent is better than nothing. Injected so this
|
||||||
|
// package does not pull in the country file.
|
||||||
|
Continent func(call string) string
|
||||||
|
Logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher owns the MQTT connection and the window.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg Config
|
||||||
|
client mqtt.Client
|
||||||
|
|
||||||
|
target string // the callsign being analysed, upper case
|
||||||
|
mode string // FT8 / FT4 — the target's mode, for the band-scope topic
|
||||||
|
band string // band tag currently subscribed to under ScopeBand
|
||||||
|
dialHz int64 // the operator's dial, for audio offsets
|
||||||
|
|
||||||
|
// subs is what we are subscribed to right now, so a target change can take
|
||||||
|
// the old filters down without guessing at their shape.
|
||||||
|
subs []string
|
||||||
|
spots []spot
|
||||||
|
|
||||||
|
// backfilled remembers the target the REST history was fetched for and when,
|
||||||
|
// so the panel's polling cannot re-fetch it every second — PSK Reporter's
|
||||||
|
// query API answers that with a rate limit, and rightly — while a target
|
||||||
|
// held for a while still gets a fresh look every few minutes.
|
||||||
|
backfilled string
|
||||||
|
backfilledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Watcher {
|
||||||
|
if cfg.Broker == "" {
|
||||||
|
cfg.Broker = DefaultBroker
|
||||||
|
}
|
||||||
|
if cfg.Scope == "" {
|
||||||
|
cfg.Scope = ScopeTarget
|
||||||
|
}
|
||||||
|
if cfg.Logf == nil {
|
||||||
|
cfg.Logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
return &Watcher{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch points the analysis at a callsign. Connects on the first call, so an
|
||||||
|
// operator who never opens the panel never opens a socket.
|
||||||
|
//
|
||||||
|
// Called repeatedly with the same target — the panel re-asserts it as the
|
||||||
|
// operator works — so everything expensive here is guarded on an actual change.
|
||||||
|
func (w *Watcher) Watch(target, mode string, dialHz int64) error {
|
||||||
|
target = strings.ToUpper(strings.TrimSpace(target))
|
||||||
|
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
if mode == "" {
|
||||||
|
mode = "FT8"
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
w.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
changed := target != w.target || mode != w.mode
|
||||||
|
w.target, w.mode = target, mode
|
||||||
|
if dialHz > 0 {
|
||||||
|
w.dialHz = dialHz
|
||||||
|
}
|
||||||
|
band := bandTag(w.dialHz)
|
||||||
|
bandChanged := band != "" && band != w.band
|
||||||
|
// Set BEFORE any connect: the subscription is built from it, and a first
|
||||||
|
// connect that found it empty would subscribe to every band at once under
|
||||||
|
// the band-wide scope — the one case where that is expensive.
|
||||||
|
if band != "" {
|
||||||
|
w.band = band
|
||||||
|
}
|
||||||
|
client := w.client
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil || !client.IsConnected() {
|
||||||
|
c, err := w.connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.client, client = c, c
|
||||||
|
w.mu.Unlock()
|
||||||
|
// connect() subscribes on its own OnConnect handler; anything below
|
||||||
|
// would only repeat it.
|
||||||
|
changed = false
|
||||||
|
bandChanged = false
|
||||||
|
}
|
||||||
|
if !changed && !bandChanged {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
// The window belongs to the target it was collected for. Keeping it across a
|
||||||
|
// change would answer the new question with the old station's evidence.
|
||||||
|
if changed {
|
||||||
|
w.spots = w.spots[:0]
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if err := w.resubscribe(client); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
// In BOTH scopes. The band-wide subscription was assumed to arrive with
|
||||||
|
// the target's reports already in the window — true only once it has been
|
||||||
|
// running a while, and false in the case that matters: the operator picks
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubscribe replaces every filter with the ones the current target and scope
|
||||||
|
// want. Takes the old ones down first: a target change that only ADDED filters
|
||||||
|
// would leave the previous station's reports arriving for ever.
|
||||||
|
func (w *Watcher) resubscribe(c mqtt.Client) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
old := w.subs
|
||||||
|
topics := w.topicsLocked()
|
||||||
|
w.subs = topics
|
||||||
|
target, scope := w.target, w.cfg.Scope
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if len(old) > 0 {
|
||||||
|
if tok := c.Unsubscribe(old...); tok.Wait() && tok.Error() != nil {
|
||||||
|
w.cfg.Logf("pskr target: unsubscribe failed: %v", tok.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(topics) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filters := make(map[string]byte, len(topics))
|
||||||
|
for _, t := range topics {
|
||||||
|
filters[t] = 0
|
||||||
|
}
|
||||||
|
if tok := c.SubscribeMultiple(filters, w.handle); tok.Wait() && tok.Error() != nil {
|
||||||
|
return fmt.Errorf("pskr target: subscribe: %w", tok.Error())
|
||||||
|
}
|
||||||
|
w.cfg.Logf("pskr target: watching %s (%s scope, %d filters)", target, scope, len(topics))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicsLocked builds the subscription list. The v2 topic is
|
||||||
|
//
|
||||||
|
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
|
||||||
|
//
|
||||||
|
// so both directions of one callsign are addressable at the broker, which is
|
||||||
|
// the whole reason the narrow scope costs almost nothing.
|
||||||
|
func (w *Watcher) topicsLocked() []string {
|
||||||
|
if w.target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if w.cfg.Scope == ScopeBand {
|
||||||
|
band := w.band
|
||||||
|
if band == "" {
|
||||||
|
band = "+"
|
||||||
|
}
|
||||||
|
return []string{"pskr/filter/v2/" + band + "/" + w.mode + "/#"}
|
||||||
|
}
|
||||||
|
out := []string{
|
||||||
|
// What he is transmitting: who is hearing him.
|
||||||
|
"pskr/filter/v2/+/" + w.mode + "/" + w.target + "/#",
|
||||||
|
// What he is receiving: the pileup, and whether the operator is in it.
|
||||||
|
"pskr/filter/v2/+/" + w.mode + "/+/" + w.target + "/#",
|
||||||
|
}
|
||||||
|
// Who hears the OPERATOR. Only some of those receivers are near the DX, and
|
||||||
|
// those are the ones that answer "can I be heard over there" on a DX who
|
||||||
|
// uploads nothing himself. Left out when the callsign is not configured
|
||||||
|
// rather than subscribing to a filter with an empty level in it.
|
||||||
|
if my := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall)); my != "" {
|
||||||
|
out = append(out, "pskr/filter/v2/+/"+w.mode+"/"+my+"/#")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) connect() (mqtt.Client, error) {
|
||||||
|
opts := mqtt.NewClientOptions().
|
||||||
|
AddBroker(w.cfg.Broker).
|
||||||
|
SetClientID(fmt.Sprintf("opslog-tgt-%d", time.Now().UnixNano())).
|
||||||
|
SetCleanSession(true).
|
||||||
|
SetAutoReconnect(true).
|
||||||
|
SetConnectRetry(true).
|
||||||
|
SetConnectRetryInterval(30 * time.Second).
|
||||||
|
SetConnectTimeout(15 * time.Second).
|
||||||
|
SetOrderMatters(false)
|
||||||
|
// Re-subscribe on every connect, reconnects included: the session is clean,
|
||||||
|
// so the broker remembers nothing and a dropped link would otherwise come
|
||||||
|
// back up subscribed to nothing at all — a panel that goes quiet for ever
|
||||||
|
// while still saying "online".
|
||||||
|
opts.OnConnect = func(c mqtt.Client) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.subs = nil
|
||||||
|
target, mode := w.target, w.mode
|
||||||
|
w.mu.Unlock()
|
||||||
|
if err := w.resubscribe(c); err != nil {
|
||||||
|
w.cfg.Logf("pskr target: %v", err)
|
||||||
|
}
|
||||||
|
if target != "" {
|
||||||
|
go w.backfill(target, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||||
|
w.cfg.Logf("pskr target: connection lost: %v (will retry)", err)
|
||||||
|
}
|
||||||
|
c := mqtt.NewClient(opts)
|
||||||
|
tok := c.Connect()
|
||||||
|
if !tok.WaitTimeout(15*time.Second) || tok.Error() != nil {
|
||||||
|
err := tok.Error()
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("timeout")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("pskr target: connect %s: %w", w.cfg.Broker, err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||||
|
var s spot
|
||||||
|
if err := json.Unmarshal(m.Payload(), &s); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.TxCall == "" || s.RxCall == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.TxCall = strings.ToUpper(s.TxCall)
|
||||||
|
s.RxCall = strings.ToUpper(s.RxCall)
|
||||||
|
s.TxGrid = strings.ToUpper(s.TxGrid)
|
||||||
|
s.RxGrid = strings.ToUpper(s.RxGrid)
|
||||||
|
s.at = time.Now()
|
||||||
|
w.mu.Lock()
|
||||||
|
w.spots = append(w.spots, s)
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop drops the target and the connection. The window goes with it: it is
|
||||||
|
// evidence about a station nobody is asking about any more.
|
||||||
|
func (w *Watcher) Stop() {
|
||||||
|
w.mu.Lock()
|
||||||
|
c := w.client
|
||||||
|
w.client, w.target, w.band, w.subs, w.backfilled = nil, "", "", nil, ""
|
||||||
|
w.spots = nil
|
||||||
|
w.mu.Unlock()
|
||||||
|
if c != nil {
|
||||||
|
c.Disconnect(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDial updates the frequency audio offsets are measured against.
|
||||||
|
func (w *Watcher) SetDial(hz int64) {
|
||||||
|
if hz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.dialHz = hz
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOperator refreshes the operator's own callsign and grid. Called when the
|
||||||
|
// station profile changes: every "near me" answer is measured from these, and a
|
||||||
|
// stale pair would quietly measure them from somebody else's station.
|
||||||
|
func (w *Watcher) SetOperator(call, grid string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.cfg.MyCall = strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
w.cfg.MyGrid = strings.ToUpper(strings.TrimSpace(grid))
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot recomputes the analysis from the window.
|
||||||
|
func (w *Watcher) Snapshot() Analysis {
|
||||||
|
// 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()
|
||||||
|
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()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-window)
|
||||||
|
kept := w.spots[:0]
|
||||||
|
for _, s := range w.spots {
|
||||||
|
if s.at.After(cutoff) {
|
||||||
|
kept = append(kept, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.spots = kept
|
||||||
|
|
||||||
|
a := Analysis{
|
||||||
|
Target: w.target,
|
||||||
|
Mode: w.mode,
|
||||||
|
Online: w.client != nil && w.client.IsConnected(),
|
||||||
|
DialHz: w.dialHz,
|
||||||
|
Spots: len(w.spots),
|
||||||
|
}
|
||||||
|
if w.target == "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
myCall := strings.ToUpper(strings.TrimSpace(w.cfg.MyCall))
|
||||||
|
myField := ""
|
||||||
|
if g := strings.ToUpper(strings.TrimSpace(w.cfg.MyGrid)); len(g) >= 2 {
|
||||||
|
myField = g[:2]
|
||||||
|
}
|
||||||
|
myCont := ""
|
||||||
|
if myField == "" && myCall != "" && w.cfg.Continent != nil {
|
||||||
|
myCont = strings.ToUpper(w.cfg.Continent(myCall))
|
||||||
|
}
|
||||||
|
|
||||||
|
// His square, taken from any report where he was transmitting. It is what
|
||||||
|
// "near him" is measured against, so without it that whole answer is
|
||||||
|
// unavailable rather than approximated.
|
||||||
|
for i := range w.spots {
|
||||||
|
if w.spots[i].TxCall == w.target && len(w.spots[i].TxGrid) >= 4 {
|
||||||
|
a.TargetGrid = w.spots[i].TxGrid[:4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := func(call, grid string, s *spot) Entry {
|
||||||
|
off := 0
|
||||||
|
if w.dialHz > 0 {
|
||||||
|
off = int(s.Freq - w.dialHz)
|
||||||
|
}
|
||||||
|
return Entry{Call: call, Grid: grid, SNR: s.SNR, OffsetHz: off,
|
||||||
|
AgeSec: int(now.Sub(s.at).Seconds())}
|
||||||
|
}
|
||||||
|
// One entry per station, overwritten as newer reports arrive, so a station
|
||||||
|
// calling every cycle counts once and shows its latest report.
|
||||||
|
heardBy := map[string]Entry{}
|
||||||
|
heardNearMe := map[string]Entry{}
|
||||||
|
fromMyArea := map[string]Entry{}
|
||||||
|
decodedBy := map[string]Entry{}
|
||||||
|
nearHim := map[string]Entry{}
|
||||||
|
pileup := map[string]struct{}{}
|
||||||
|
pileupCutoff := now.Add(-pileupWindow)
|
||||||
|
|
||||||
|
type acc struct {
|
||||||
|
n int
|
||||||
|
sum float64
|
||||||
|
}
|
||||||
|
bins := map[int]*acc{}
|
||||||
|
var lastHeMe *spot
|
||||||
|
|
||||||
|
near := func(theirGrid, call string) bool {
|
||||||
|
if myField != "" {
|
||||||
|
return strings.HasPrefix(strings.ToUpper(theirGrid), myField)
|
||||||
|
}
|
||||||
|
if myCont != "" && w.cfg.Continent != nil {
|
||||||
|
return strings.ToUpper(w.cfg.Continent(call)) == myCont
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range w.spots {
|
||||||
|
s := &w.spots[i]
|
||||||
|
|
||||||
|
// He transmitted: somebody heard him.
|
||||||
|
if s.TxCall == w.target {
|
||||||
|
e := entry(s.RxCall, s.RxGrid, s)
|
||||||
|
heardBy[s.RxCall] = e
|
||||||
|
if near(s.RxGrid, s.RxCall) {
|
||||||
|
heardNearMe[s.RxCall] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator transmitted and a station in the DX's own square heard
|
||||||
|
// it. That is a path to his region, proved without his help.
|
||||||
|
if a.TargetGrid != "" && myCall != "" && s.TxCall == myCall &&
|
||||||
|
strings.HasPrefix(s.RxGrid, a.TargetGrid) {
|
||||||
|
nearHim[s.RxCall] = entry(s.RxCall, s.RxGrid, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// He received: this is the pileup, the passband, and the answer.
|
||||||
|
if s.RxCall == w.target {
|
||||||
|
a.DecodesInWindow++
|
||||||
|
if s.TxCall == myCall {
|
||||||
|
if lastHeMe == nil || s.at.After(lastHeMe.at) {
|
||||||
|
lastHeMe = s
|
||||||
|
}
|
||||||
|
continue // the operator is not part of his own pileup
|
||||||
|
}
|
||||||
|
decodedBy[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
|
||||||
|
if s.at.After(pileupCutoff) {
|
||||||
|
pileup[s.TxCall] = struct{}{}
|
||||||
|
}
|
||||||
|
if near(s.TxGrid, s.TxCall) {
|
||||||
|
fromMyArea[s.TxCall] = entry(s.TxCall, s.TxGrid, s)
|
||||||
|
}
|
||||||
|
if w.dialHz > 0 {
|
||||||
|
off := int(s.Freq - w.dialHz)
|
||||||
|
if off >= lowHz && off <= highHz {
|
||||||
|
edge := (off / binHz) * binHz
|
||||||
|
b := bins[edge]
|
||||||
|
if b == nil {
|
||||||
|
b = &acc{}
|
||||||
|
bins[edge] = b
|
||||||
|
}
|
||||||
|
b.n++
|
||||||
|
b.sum += float64(s.SNR)
|
||||||
|
if off > a.CeilingHz {
|
||||||
|
a.CeilingHz = off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastHeMe != nil {
|
||||||
|
a.HeMe = true
|
||||||
|
a.HeMeSeconds = int(now.Sub(lastHeMe.at).Seconds())
|
||||||
|
a.HeMeSNR = lastHeMe.SNR
|
||||||
|
if w.dialHz > 0 {
|
||||||
|
a.HeMeOffset = int(lastHeMe.Freq - w.dialHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.TargetUploads = a.DecodesInWindow > 0
|
||||||
|
a.HeardByCount = len(heardBy)
|
||||||
|
a.HeardNearMe = len(heardNearMe)
|
||||||
|
a.HeardNearMeTop = top(heardNearMe, 5)
|
||||||
|
a.FromMyAreaCount = len(fromMyArea)
|
||||||
|
a.FromMyAreaTop = top(fromMyArea, 5)
|
||||||
|
a.PathOpen = a.FromMyAreaCount > 0
|
||||||
|
a.NearHimCount = len(nearHim)
|
||||||
|
a.NearHimTop = top(nearHim, 5)
|
||||||
|
a.DecodedByCount = len(decodedBy)
|
||||||
|
a.DecodedByTop = top(decodedBy, 10)
|
||||||
|
a.DecodedByCalls = make([]string, 0, len(decodedBy))
|
||||||
|
for c := range decodedBy {
|
||||||
|
a.DecodedByCalls = append(a.DecodedByCalls, c)
|
||||||
|
}
|
||||||
|
sort.Strings(a.DecodedByCalls)
|
||||||
|
a.PileupCount = len(pileup)
|
||||||
|
|
||||||
|
a.Bins = make([]Bin, 0, len(bins))
|
||||||
|
for edge, b := range bins {
|
||||||
|
avg := 0.0
|
||||||
|
if b.n > 0 {
|
||||||
|
avg = b.sum / float64(b.n)
|
||||||
|
}
|
||||||
|
a.Bins = append(a.Bins, Bin{OffsetHz: edge, Count: b.n, AvgSNR: avg})
|
||||||
|
}
|
||||||
|
sort.Slice(a.Bins, func(i, j int) bool { return a.Bins[i].OffsetHz < a.Bins[j].OffsetHz })
|
||||||
|
a.SuggestedOffset = suggestOffset(a.Bins, a.CeilingHz)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// top returns the freshest entries from a per-callsign map, newest first.
|
||||||
|
func top(m map[string]Entry, limit int) []Entry {
|
||||||
|
out := make([]Entry, 0, len(m))
|
||||||
|
for _, e := range m {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].AgeSec < out[j].AgeSec })
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestOffset picks an audio slot to call on.
|
||||||
|
//
|
||||||
|
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
|
||||||
|
// has actually decoded, and it is the only evidence available about how wide
|
||||||
|
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
|
||||||
|
// somebody whose passband stops at 2700 is advice to transmit into a filter.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
if ceiling < 1000 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := make(map[int]int, len(bins))
|
||||||
|
busy := make(map[int]bool, len(bins)*3)
|
||||||
|
for _, b := range bins {
|
||||||
|
count[b.OffsetHz] = b.Count
|
||||||
|
if b.Count > 0 {
|
||||||
|
// 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.
|
||||||
|
busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// From 1000 Hz up: below that is where every default transmit offset sits,
|
||||||
|
// so it is the most crowded part of the passband and the least useful advice.
|
||||||
|
const low = 1020
|
||||||
|
high := (ceiling / binHz) * binHz
|
||||||
|
|
||||||
|
// 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
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = edge
|
||||||
|
}
|
||||||
|
run++
|
||||||
|
if run > bestLen {
|
||||||
|
bestStart, bestLen = start, run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// vocabulary ("20m"). Only used by the band-wide scope, to subscribe to one
|
||||||
|
// band instead of all of them.
|
||||||
|
func bandTag(hz int64) string {
|
||||||
|
khz := hz / 1000
|
||||||
|
switch {
|
||||||
|
case khz >= 1800 && khz <= 2000:
|
||||||
|
return "160m"
|
||||||
|
case khz >= 3500 && khz <= 4000:
|
||||||
|
return "80m"
|
||||||
|
case khz >= 5250 && khz <= 5450:
|
||||||
|
return "60m"
|
||||||
|
case khz >= 7000 && khz <= 7300:
|
||||||
|
return "40m"
|
||||||
|
case khz >= 10100 && khz <= 10150:
|
||||||
|
return "30m"
|
||||||
|
case khz >= 14000 && khz <= 14350:
|
||||||
|
return "20m"
|
||||||
|
case khz >= 18068 && khz <= 18168:
|
||||||
|
return "17m"
|
||||||
|
case khz >= 21000 && khz <= 21450:
|
||||||
|
return "15m"
|
||||||
|
case khz >= 24890 && khz <= 24990:
|
||||||
|
return "12m"
|
||||||
|
case khz >= 28000 && khz <= 29700:
|
||||||
|
return "10m"
|
||||||
|
case khz >= 50000 && khz <= 54000:
|
||||||
|
return "6m"
|
||||||
|
case khz >= 70000 && khz <= 70500:
|
||||||
|
return "4m"
|
||||||
|
case khz >= 144000 && khz <= 148000:
|
||||||
|
return "2m"
|
||||||
|
case khz >= 430000 && khz <= 440000:
|
||||||
|
return "70cm"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REST backfill ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The narrow subscription starts empty, and five minutes of waiting is not an
|
||||||
|
// answer to "should I call this station now". PSK Reporter's query API hands
|
||||||
|
// back the last quarter hour in one request, so the window is populated before
|
||||||
|
// the first cycle finishes.
|
||||||
|
//
|
||||||
|
// Fetched ONCE per target. The panel polls every second, and a query per poll
|
||||||
|
// is what gets an application rate-limited off the service for everyone.
|
||||||
|
|
||||||
|
type pskrReport struct {
|
||||||
|
Sender string `xml:"senderCallsign,attr"`
|
||||||
|
SenderGrid string `xml:"senderLocator,attr"`
|
||||||
|
Receiver string `xml:"receiverCallsign,attr"`
|
||||||
|
ReceiverGrid string `xml:"receiverLocator,attr"`
|
||||||
|
Frequency string `xml:"frequency,attr"`
|
||||||
|
SNR string `xml:"sNR,attr"`
|
||||||
|
Mode string `xml:"mode,attr"`
|
||||||
|
FlowStartSecs string `xml:"flowStartSeconds,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pskrReports struct {
|
||||||
|
XMLName xml.Name `xml:"receptionReports"`
|
||||||
|
Reports []pskrReport `xml:"receptionReport"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfill fetches the last quarter hour for a target, in BOTH directions.
|
||||||
|
//
|
||||||
|
// Two queries, because the panel asks two questions and the service answers
|
||||||
|
// them separately: what the target RECEIVED (his pileup, the passband, whether
|
||||||
|
// he decoded us) and what he TRANSMITTED (who is hearing him, and how much of
|
||||||
|
// that is near us). The live feed fills both eventually; a target picked ten
|
||||||
|
// seconds ago has neither, and with the narrow subscription there is nothing in
|
||||||
|
// the window at all until his own uploader next reports.
|
||||||
|
//
|
||||||
|
// Fetched ONCE per target. The panel polls every second, and a query per poll
|
||||||
|
// is what gets an application rate-limited off the service for everyone.
|
||||||
|
func (w *Watcher) backfill(target, mode string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
// 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()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.backfilled, w.backfilledAt = target, time.Now()
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
got := 0
|
||||||
|
for _, dir := range []struct{ param, what string }{
|
||||||
|
{"receiverCallsign", "decoded by him"},
|
||||||
|
{"senderCallsign", "who is hearing him"},
|
||||||
|
} {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set(dir.param, target)
|
||||||
|
q.Set("mode", mode)
|
||||||
|
q.Set("flowStartSeconds", strconv.Itoa(-900))
|
||||||
|
q.Set("nolocator", "0")
|
||||||
|
// The pskquery5 endpoint rather than retrieve.pskreporter.info: this is
|
||||||
|
// the one DXHunter has been using against the live service, and a
|
||||||
|
// backfill that silently returns nothing is worse than none at all.
|
||||||
|
req, err := http.NewRequest("GET", "https://pskreporter.info/cgi-bin/pskquery5.pl?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog (PSK Reporter target analysis)")
|
||||||
|
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
w.cfg.Logf("pskr target: history for %s (%s) unavailable: %v", target, dir.what, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 503 is the service saying "too often". Worth a line, because the
|
||||||
|
// panel then fills at the live feed's pace and looks slow for no
|
||||||
|
// visible reason.
|
||||||
|
w.cfg.Logf("pskr target: history for %s (%s) refused (HTTP %d)", target, dir.what, resp.StatusCode)
|
||||||
|
resp.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rr pskrReports
|
||||||
|
err = xml.NewDecoder(resp.Body).Decode(&rr)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got += w.absorb(target, rr.Reports)
|
||||||
|
}
|
||||||
|
if got > 0 {
|
||||||
|
w.cfg.Logf("pskr target: %d recent reports for %s from the history queries", got, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// absorb adds fetched reports to the window, skipping what the live feed has
|
||||||
|
// already delivered. Without the check the same report arrives twice — once by
|
||||||
|
// MQTT, once by query — and every count that is not per-callsign doubles: the
|
||||||
|
// decode total, and the bars of the passband.
|
||||||
|
func (w *Watcher) absorb(target string, reports []pskrReport) int {
|
||||||
|
now := time.Now()
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
// Still the same target? The operator may have moved on while this was in
|
||||||
|
// flight, and dropping a stale answer into the window would attribute one
|
||||||
|
// station's pileup to another.
|
||||||
|
if w.target != target {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
type key struct {
|
||||||
|
tx, rx string
|
||||||
|
hz int64
|
||||||
|
}
|
||||||
|
seen := make(map[key]bool, len(w.spots))
|
||||||
|
for i := range w.spots {
|
||||||
|
seen[key{w.spots[i].TxCall, w.spots[i].RxCall, w.spots[i].Freq}] = true
|
||||||
|
}
|
||||||
|
added := 0
|
||||||
|
for _, r := range reports {
|
||||||
|
hz, _ := strconv.ParseInt(r.Frequency, 10, 64)
|
||||||
|
snr, _ := strconv.Atoi(r.SNR)
|
||||||
|
k := key{strings.ToUpper(r.Sender), strings.ToUpper(r.Receiver), hz}
|
||||||
|
if hz == 0 || seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := now
|
||||||
|
if secs, err := strconv.ParseInt(r.FlowStartSecs, 10, 64); err == nil {
|
||||||
|
switch {
|
||||||
|
case secs > 1_000_000_000:
|
||||||
|
at = time.Unix(secs, 0) // an absolute time
|
||||||
|
case secs < 0:
|
||||||
|
at = now.Add(time.Duration(secs) * time.Second) // an age in seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stamped with its REAL age, so it ages out of the window on its own and
|
||||||
|
// a quarter-hour-old decode is never read as "he heard you just now".
|
||||||
|
if at.Before(now.Add(-window)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
w.spots = append(w.spots, spot{
|
||||||
|
Freq: hz, Mode: strings.ToUpper(r.Mode), SNR: snr,
|
||||||
|
TxCall: k.tx, TxGrid: strings.ToUpper(r.SenderGrid),
|
||||||
|
RxCall: k.rx, RxGrid: strings.ToUpper(r.ReceiverGrid),
|
||||||
|
at: at,
|
||||||
|
})
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package pskrtgt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feed builds a watcher with a window already populated, so the analysis can be
|
||||||
|
// pinned without a broker.
|
||||||
|
func feed(target string, spots ...spot) *Watcher {
|
||||||
|
w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"})
|
||||||
|
w.target, w.mode, w.dialHz = target, "FT8", 14_074_000
|
||||||
|
w.spots = spots
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot {
|
||||||
|
return spot{
|
||||||
|
TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid,
|
||||||
|
SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) {
|
||||||
|
w := feed("YI5RLS",
|
||||||
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second),
|
||||||
|
rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second),
|
||||||
|
)
|
||||||
|
a := w.Snapshot()
|
||||||
|
if !a.HeMe {
|
||||||
|
t.Fatal("the DX decoded the operator and the panel says he did not")
|
||||||
|
}
|
||||||
|
if a.HeMeSNR != -14 || a.HeMeOffset != 1200 {
|
||||||
|
t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset)
|
||||||
|
}
|
||||||
|
// The operator is not part of the pileup he is calling into: counting
|
||||||
|
// yourself as competition is how a "1 caller" band looks contested.
|
||||||
|
if a.PileupCount != 1 {
|
||||||
|
t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount)
|
||||||
|
}
|
||||||
|
// F4XYZ shares the operator's Maidenhead field, so the path from this
|
||||||
|
// region is demonstrably open.
|
||||||
|
if !a.PathOpen || a.FromMyAreaCount != 1 {
|
||||||
|
t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) {
|
||||||
|
// He transmits (so his square is known), and a station in that square hears
|
||||||
|
// the operator. He himself has decoded nobody.
|
||||||
|
w := feed("YI5RLS",
|
||||||
|
rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second),
|
||||||
|
rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second),
|
||||||
|
)
|
||||||
|
a := w.Snapshot()
|
||||||
|
if a.TargetGrid != "LM43" {
|
||||||
|
t.Fatalf("his square = %q, want LM43", a.TargetGrid)
|
||||||
|
}
|
||||||
|
if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" {
|
||||||
|
t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop)
|
||||||
|
}
|
||||||
|
// He uploads nothing: the panel must be able to say so, or an operator
|
||||||
|
// reads an empty panel as a closed band.
|
||||||
|
if a.TargetUploads {
|
||||||
|
t.Error("he received nothing in the window, yet the panel claims he uploads")
|
||||||
|
}
|
||||||
|
if a.HeMe {
|
||||||
|
t.Error("nobody reported HIM decoding the operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowDropsWhatIsTooOld(t *testing.T) {
|
||||||
|
// Inside the window: a report from six minutes ago is still evidence. Five
|
||||||
|
// minutes was too short — measured against DXHunter on the same station at
|
||||||
|
// the same moment, it hid a third of the decodes and a co-area station.
|
||||||
|
w := feed("YI5RLS",
|
||||||
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute),
|
||||||
|
)
|
||||||
|
if a := w.Snapshot(); !a.HeMe {
|
||||||
|
t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a)
|
||||||
|
}
|
||||||
|
// Past it, it goes.
|
||||||
|
w = feed("YI5RLS",
|
||||||
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute),
|
||||||
|
)
|
||||||
|
if a := w.Snapshot(); a.HeMe || a.Spots != 0 {
|
||||||
|
t.Errorf("an eleven-minute-old report survived: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
|
||||||
|
// Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400.
|
||||||
|
bins := []Bin{}
|
||||||
|
for hz := 1020; hz <= 1500; hz += binHz {
|
||||||
|
bins = append(bins, Bin{OffsetHz: hz, Count: 3})
|
||||||
|
}
|
||||||
|
bins = append(bins, Bin{OffsetHz: 2400, Count: 1})
|
||||||
|
got := suggestOffset(bins, 2400)
|
||||||
|
if got < 1620 || got > 2340 {
|
||||||
|
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
|
||||||
|
}
|
||||||
|
// A passband with no gap at all still gets an answer — the quietest slot,
|
||||||
|
// which is what an operator would look for by eye. What it must never do is
|
||||||
|
// advise ABOVE the ceiling: transmitting past the DX's filter is the one
|
||||||
|
// 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 {
|
||||||
|
t.Errorf("suggested %d Hz with no data at all, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopicsFollowTheScope(t *testing.T) {
|
||||||
|
w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget})
|
||||||
|
w.target, w.mode, w.band = "YI5RLS", "FT8", "20m"
|
||||||
|
got := w.topicsLocked()
|
||||||
|
want := []string{
|
||||||
|
"pskr/filter/v2/+/FT8/YI5RLS/#",
|
||||||
|
"pskr/filter/v2/+/FT8/+/YI5RLS/#",
|
||||||
|
"pskr/filter/v2/+/FT8/F4BPO/#",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("narrow scope = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("filter %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.cfg.Scope = ScopeBand
|
||||||
|
if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" {
|
||||||
|
t.Errorf("band scope = %v, want the one band-wide filter", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user