Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2007479da6 | ||
|
|
4ba5569ca8 | ||
|
|
dc0b00b474 | ||
|
|
2695747db6 | ||
|
|
eba54344b0 | ||
|
|
93879a0ce1 | ||
|
|
8fccfa29b1 | ||
|
|
784d809290 | ||
|
|
700b688fc0 | ||
|
|
03598cfaff | ||
|
|
ed517eb8d3 | ||
|
|
1cfba19ed2 | ||
|
|
b18f5c491e | ||
|
|
741364bf99 | ||
|
|
d0f06d9670 | ||
|
|
8e49d37cbd | ||
|
|
b2382a6135 | ||
|
|
f8fb57210f | ||
|
|
8762131248 | ||
|
|
e5f79b18c2 | ||
|
|
1605e30f60 | ||
|
|
6aff322be6 | ||
|
|
ed3c132f70 | ||
|
|
385e8c4c86 | ||
|
|
61e5736f1e | ||
|
|
6ca808a6c9 | ||
|
|
f698096f53 | ||
|
|
ef628e066a | ||
|
|
4ffdfc2548 | ||
|
|
83e7727aab | ||
|
|
1348dc2177 | ||
|
|
57ac382f91 | ||
|
|
858c04d267 | ||
|
|
2484cd2515 | ||
|
|
62256942a1 | ||
|
|
3c8aadf41f | ||
|
|
2f5fd63afd | ||
|
|
4f3c3edaee | ||
|
|
7d33379fe1 | ||
|
|
82a49150d2 | ||
|
|
1ef9e553f7 | ||
|
|
39fab68bd4 | ||
|
|
bbddba6e74 | ||
|
|
b659f6f16d | ||
|
|
b4ad12bdc6 | ||
|
|
5dd3532074 | ||
|
|
dd7b63c059 | ||
|
|
8fc6673611 | ||
|
|
d33291b521 | ||
|
|
1b32b1ddec | ||
|
|
65bbaa85f3 | ||
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c |
+159
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
// Row colouring for the log grid, by QSL / LoTW status — the thing Logger32 does
|
||||
// and the reason an operator can tell at a glance what still needs sending.
|
||||
//
|
||||
// Rules are ORDERED and the first match wins, because a contact is usually
|
||||
// several things at once: one confirmed on LoTW and by card is confirmed, not
|
||||
// "sent, awaiting reply". Putting the order in the data rather than in a chain
|
||||
// of ifs is what lets the settings panel show it in the same order it applies.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RowColorRule is one status and the colour it paints.
|
||||
type RowColorRule struct {
|
||||
ID string `json:"id"`
|
||||
Color string `json:"color"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Channels this rule looks at: qsl (paper), lotw, eqsl, qrz.
|
||||
//
|
||||
// Empty means ALL of them — what an operator expects from a rule they have
|
||||
// not narrowed, and what keeps a config written before this field meaningful.
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// The QSL channels a rule can be scoped to.
|
||||
var rowColorChannels = []string{"qsl", "lotw", "eqsl", "qrz"}
|
||||
|
||||
// RowColorSettings is the whole appearance block.
|
||||
type RowColorSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Style is how the colour is shown: "bar" paints a stripe down the left edge,
|
||||
// "tint" washes the row, "both" does each.
|
||||
//
|
||||
// A bar is the default because a filled row is a poor signal in a log where
|
||||
// nearly every contact has SOME QSL state: colour that is always present
|
||||
// stops being information and becomes a striped background, with the data
|
||||
// behind it. The stripe says the same thing and costs nothing to read.
|
||||
Style string `json:"style"`
|
||||
// Intensity is the tint strength in percent. Only used by "tint"/"both".
|
||||
Intensity int `json:"intensity"`
|
||||
// BandMapLotw marks stations that upload to LoTW on the band map, with the
|
||||
// same "L" badge the cluster list uses — one visual vocabulary across the two
|
||||
// views rather than a second invention.
|
||||
BandMapLotw bool `json:"bandmap_lotw"`
|
||||
// Configured distinguishes "saved with this off" from "saved before the
|
||||
// option existed", so a new marker can default ON without silently turning
|
||||
// itself back on for an operator who switched it off.
|
||||
Configured bool `json:"configured"`
|
||||
Rules []RowColorRule `json:"rules"`
|
||||
}
|
||||
|
||||
// The rule ids, in priority order. The frontend matches on these and holds the
|
||||
// labels, so a translated name never has to travel through the settings.
|
||||
// The four categories, in the order they are tested — first match wins.
|
||||
//
|
||||
// "To send" comes FIRST on purpose. A contact can be confirmed on LoTW and
|
||||
// still owe a paper card, and the colour an operator scans for is the one that
|
||||
// means "there is something left to do here". Put after "confirmed", that row
|
||||
// would go green and the card would never be printed.
|
||||
//
|
||||
// "Worked" is the catch-all: a contact with nothing sent, nothing asked for and
|
||||
// nothing back. Off by default — with it on, every remaining row is painted.
|
||||
var rowColorOrder = []string{
|
||||
"to_send", // requested or queued on a watched channel, not gone out
|
||||
"confirmed", // a watched channel has a confirmation back
|
||||
"sent", // gone out on a watched channel, nothing back yet
|
||||
"worked", // none of the above
|
||||
}
|
||||
|
||||
// Defaults: green for done, amber for waiting, blue for owed. Deliberately
|
||||
// muted — they are composited at low opacity over a dark grid, and a saturated
|
||||
// value there reads as an error state rather than a status.
|
||||
var rowColorDefaults = map[string]string{
|
||||
"to_send": "#a855f7",
|
||||
"confirmed": "#16a34a",
|
||||
"sent": "#f59e0b",
|
||||
"worked": "#64748b",
|
||||
}
|
||||
|
||||
// hexColor guards what reaches the stylesheet. The value is interpolated into a
|
||||
// CSS color-mix() by the grid, so anything that is not plainly a hex colour is
|
||||
// refused rather than passed through.
|
||||
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
func normRowColors(s RowColorSettings) RowColorSettings {
|
||||
byID := map[string]RowColorRule{}
|
||||
for _, r := range s.Rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
out := RowColorSettings{
|
||||
Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity,
|
||||
BandMapLotw: s.BandMapLotw, Configured: true,
|
||||
}
|
||||
if !s.Configured {
|
||||
out.BandMapLotw = true // new option, on unless the operator says otherwise
|
||||
}
|
||||
switch out.Style {
|
||||
case "bar", "tint", "both":
|
||||
default:
|
||||
out.Style = "bar"
|
||||
}
|
||||
// Clamped rather than rejected: the value only shapes a colour-mix, and a
|
||||
// number outside the range is a slider that got away, not a fault worth
|
||||
// resetting the operator's whole choice for.
|
||||
if out.Intensity < 5 {
|
||||
out.Intensity = 12
|
||||
} else if out.Intensity > 45 {
|
||||
out.Intensity = 45
|
||||
}
|
||||
ok := map[string]bool{}
|
||||
for _, c := range rowColorChannels {
|
||||
ok[c] = true
|
||||
}
|
||||
for _, id := range rowColorOrder {
|
||||
r := byID[id]
|
||||
r.ID = id
|
||||
if !hexColor.MatchString(strings.TrimSpace(r.Color)) {
|
||||
r.Color = rowColorDefaults[id]
|
||||
}
|
||||
// Keep only channels we know, in the canonical order, de-duplicated.
|
||||
want := map[string]bool{}
|
||||
for _, c := range r.Channels {
|
||||
want[strings.ToLower(strings.TrimSpace(c))] = true
|
||||
}
|
||||
chans := []string{}
|
||||
for _, c := range rowColorChannels {
|
||||
if want[c] {
|
||||
chans = append(chans, c)
|
||||
}
|
||||
}
|
||||
r.Channels = chans
|
||||
out.Rules = append(out.Rules, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||
// panel never has to invent one.
|
||||
func (a *App) GetRowColors() RowColorSettings {
|
||||
var s RowColorSettings
|
||||
if raw := a.settingOr(keyRowColors, ""); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &s)
|
||||
}
|
||||
return normRowColors(s)
|
||||
}
|
||||
|
||||
// SaveRowColors persists it.
|
||||
func (a *App) SaveRowColors(s RowColorSettings) error {
|
||||
b, err := json.Marshal(normRowColors(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyRowColors, string(b))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The colour is interpolated into a CSS color-mix() by the grid, so anything
|
||||
// that is not plainly a hex colour has to be refused rather than passed on.
|
||||
func TestRowColorsRefuseAnythingButHex(t *testing.T) {
|
||||
in := RowColorSettings{Enabled: true, Rules: []RowColorRule{
|
||||
{ID: "confirmed", Color: "#123abc", Enabled: true},
|
||||
{ID: "sent", Color: "red; background:url(x)", Enabled: true},
|
||||
{ID: "to_send", Color: "", Enabled: true},
|
||||
}}
|
||||
got := normRowColors(in)
|
||||
|
||||
byID := map[string]RowColorRule{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
if byID["confirmed"].Color != "#123abc" {
|
||||
t.Errorf("a valid colour was rewritten: %q", byID["confirmed"].Color)
|
||||
}
|
||||
if byID["sent"].Color != rowColorDefaults["sent"] {
|
||||
t.Errorf("an injection attempt survived: %q", byID["sent"].Color)
|
||||
}
|
||||
if byID["to_send"].Color != rowColorDefaults["to_send"] {
|
||||
t.Errorf("an empty colour was kept: %q", byID["to_send"].Color)
|
||||
}
|
||||
}
|
||||
|
||||
// Priority lives in the data, not in a chain of ifs: a contact confirmed on
|
||||
// LoTW AND by card is confirmed, not "sent, awaiting reply". The panel shows
|
||||
// the rules in the order they apply, so that order must survive a round trip.
|
||||
func TestRowColorsKeepPriorityOrder(t *testing.T) {
|
||||
// Saved in a jumbled order, as a hand-edited settings row could be.
|
||||
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||
{ID: "to_send", Color: "#111111"},
|
||||
{ID: "confirmed", Color: "#222222"},
|
||||
}})
|
||||
if len(got.Rules) != len(rowColorOrder) {
|
||||
t.Fatalf("got %d rules, want every one present", len(got.Rules))
|
||||
}
|
||||
for i, id := range rowColorOrder {
|
||||
if got.Rules[i].ID != id {
|
||||
t.Errorf("rule %d is %q, want %q", i, got.Rules[i].ID, id)
|
||||
}
|
||||
}
|
||||
// The saved colours survived the reordering: each stayed with its own rule.
|
||||
byID := map[string]string{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r.Color
|
||||
}
|
||||
if byID["to_send"] != "#111111" || byID["confirmed"] != "#222222" {
|
||||
t.Errorf("colours moved between rules: %v", byID)
|
||||
}
|
||||
}
|
||||
|
||||
// Style and strength are normalised, not rejected: they only shape a colour, and
|
||||
// a value that got away is a slider, not a fault worth resetting the operator's
|
||||
// whole choice for.
|
||||
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
inStyle string
|
||||
inPct int
|
||||
wantStyle string
|
||||
wantPct int
|
||||
}{
|
||||
{"bar", 12, "bar", 12},
|
||||
{"tint", 30, "tint", 30},
|
||||
{"both", 45, "both", 45},
|
||||
{"", 0, "bar", 12}, // never configured
|
||||
{"wallpaper", 12, "bar", 12}, // not a style we draw
|
||||
{"tint", 900, "tint", 45}, // clamped, not reset
|
||||
{"tint", -5, "tint", 12},
|
||||
} {
|
||||
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
|
||||
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
|
||||
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
|
||||
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channels are normalised to the canonical order, and anything unknown is
|
||||
// dropped. Empty stays empty, because empty means "every channel" — a rule the
|
||||
// operator has not narrowed must not silently become a rule about nothing.
|
||||
func TestRowColorChannelsNormalise(t *testing.T) {
|
||||
got := normRowColors(RowColorSettings{Rules: []RowColorRule{
|
||||
{ID: "confirmed", Color: "#111111", Channels: []string{"LOTW", "pigeon", "qsl", "lotw"}},
|
||||
{ID: "sent", Color: "#222222"},
|
||||
}})
|
||||
byID := map[string][]string{}
|
||||
for _, r := range got.Rules {
|
||||
byID[r.ID] = r.Channels
|
||||
}
|
||||
if len(byID["confirmed"]) != 2 || byID["confirmed"][0] != "qsl" || byID["confirmed"][1] != "lotw" {
|
||||
t.Errorf("confirmed channels = %v, want [qsl lotw] — canonical order, deduped, unknown dropped", byID["confirmed"])
|
||||
}
|
||||
if len(byID["sent"]) != 0 {
|
||||
t.Errorf("sent channels = %v, want empty (meaning every channel)", byID["sent"])
|
||||
}
|
||||
}
|
||||
|
||||
// The order IS the priority, and "to send" leads. A contact confirmed on LoTW
|
||||
// that still owes a paper card must read as owing the card — put after
|
||||
// "confirmed" it would go green and never be printed.
|
||||
func TestToSendOutranksConfirmed(t *testing.T) {
|
||||
got := normRowColors(RowColorSettings{})
|
||||
if got.Rules[0].ID != "to_send" {
|
||||
t.Errorf("first rule is %q, want to_send", got.Rules[0].ID)
|
||||
}
|
||||
if got.Rules[len(got.Rules)-1].ID != "worked" {
|
||||
t.Errorf("last rule is %q, want worked as the catch-all", got.Rules[len(got.Rules)-1].ID)
|
||||
}
|
||||
}
|
||||
+47
-6
@@ -21,6 +21,8 @@ import (
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/bandopen"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/geo"
|
||||
"hamlog/internal/gridcache"
|
||||
"hamlog/internal/pskr"
|
||||
)
|
||||
|
||||
@@ -126,19 +128,54 @@ func (a *App) startBandOpenFeed() {
|
||||
a.pskr = nil
|
||||
}
|
||||
s := a.GetBandOpenSettings()
|
||||
a.bandOpen.on.Store(s.Enabled)
|
||||
a.setBandOpenBands(s.Bands)
|
||||
if !s.Enabled {
|
||||
// Put out whatever is currently lit. Leaving the badges up would keep
|
||||
// announcing an opening from a watch that is now off, and they only fade
|
||||
// on a timer fed by spots this path no longer looks at — so they would
|
||||
// hang there until the app restarted.
|
||||
a.clearBandOpenings()
|
||||
}
|
||||
chaseGrids := a.gridStore != nil
|
||||
if !s.Enabled && !chaseGrids {
|
||||
return
|
||||
}
|
||||
// Every spot is measured from the operator's position. Without one there is
|
||||
// nothing to measure, and a detector fed unmeasurable spots reports nothing
|
||||
// while looking like it is working.
|
||||
if !a.opSet {
|
||||
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
|
||||
applog.Printf("pskr: no station grid set — the feed needs one to measure a path")
|
||||
return
|
||||
}
|
||||
|
||||
// Grid chasing wants every band; the opening watch wants its four. "+" is the
|
||||
// MQTT single-level wildcard, so one subscription per square covers the lot.
|
||||
bands := s.Bands
|
||||
if chaseGrids {
|
||||
bands = []string{"+"}
|
||||
}
|
||||
// Filter at the BROKER on the receiver's square rather than receiving the
|
||||
// world and discarding it here. Measured on the live feed: the four opening
|
||||
// bands unfiltered are 83 messages a second, of which roughly one in a
|
||||
// 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,
|
||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
||||
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
||||
|
||||
var onGrid func(call, grid string)
|
||||
if chaseGrids {
|
||||
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
||||
}
|
||||
var onSpot func(pskr.Spot)
|
||||
if s.Enabled {
|
||||
onSpot = a.feedBandOpen
|
||||
}
|
||||
|
||||
a.pskr = pskr.New(pskr.Config{
|
||||
Bands: s.Bands,
|
||||
OpLat: a.opLat, OpLon: a.opLon,
|
||||
Bands: bands,
|
||||
RxGrids: rxGrids,
|
||||
OpLat: a.opLat, OpLon: a.opLon,
|
||||
Geo: func(grid string) (int, int, bool) {
|
||||
lat, lon, ok := gridToLatLon(grid)
|
||||
if !ok {
|
||||
@@ -150,12 +187,16 @@ func (a *App) startBandOpenFeed() {
|
||||
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
|
||||
return d, b, true
|
||||
},
|
||||
OnSpot: a.feedBandOpen,
|
||||
OnSpot: onSpot,
|
||||
OnGrid: onGrid,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
if err := a.pskr.Start(); err != nil {
|
||||
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
|
||||
applog.Printf("pskr: feed did not start: %v", err)
|
||||
return
|
||||
}
|
||||
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
|
||||
bands, len(rxGrids), s.Enabled, chaseGrids)
|
||||
}
|
||||
|
||||
// feedBandOpen hands one PSK Reporter decode to the detector.
|
||||
@@ -164,7 +205,7 @@ func (a *App) startBandOpenFeed() {
|
||||
// so it does the least possible: the detector's own window and de-duplication
|
||||
// by callsign are what turn that flood into one announcement.
|
||||
func (a *App) feedBandOpen(s pskr.Spot) {
|
||||
if !bandopen.Watched(s.Band) {
|
||||
if !a.bandOpenWanted(s.Band) {
|
||||
return
|
||||
}
|
||||
a.bandOpen.mu.Lock()
|
||||
|
||||
+76
-1
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
@@ -21,7 +22,21 @@ import (
|
||||
)
|
||||
|
||||
type bandOpenState struct {
|
||||
mu sync.Mutex
|
||||
// on mirrors the "Watch for band openings" setting.
|
||||
//
|
||||
// Cached rather than read per spot: this is the cluster hot path, where a
|
||||
// settings query per spot is exactly what the rest of this file avoids.
|
||||
// startBandOpenFeed owns it — it runs at startup and again on every save, so
|
||||
// the switch takes effect without a restart.
|
||||
on atomic.Bool
|
||||
// bands is the operator's SELECTED set, held as map[string]bool.
|
||||
//
|
||||
// bandopen.Watched only says which bands the detector is capable of; it
|
||||
// knows nothing about the chips in Settings. Nothing checked the selection
|
||||
// on either feed path, so unticking 10 m and 2 m changed the subscription and
|
||||
// left the cluster path announcing them anyway.
|
||||
bands atomic.Value
|
||||
mu sync.Mutex
|
||||
det *bandopen.Detector
|
||||
last []bandopen.Opening // most recent first, for the UI
|
||||
// live holds the announced openings that are still going, keyed by band, and
|
||||
@@ -45,6 +60,16 @@ const maxRememberedOpenings = 20
|
||||
|
||||
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||
// The watch has to be switched on.
|
||||
//
|
||||
// It was not checked here at all: the setting only ever governed the extra
|
||||
// DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector
|
||||
// itself ran on every ordinary cluster spot. So an operator who had never
|
||||
// enabled the watch still got opening banners, from a feature they had
|
||||
// deliberately left off.
|
||||
if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
|
||||
return
|
||||
}
|
||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||
// detection rests on those two. Say nothing rather than guess.
|
||||
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||
@@ -122,6 +147,56 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
|
||||
return out
|
||||
}
|
||||
|
||||
// bandOpenWanted reports whether the operator has this band ticked. Falls back
|
||||
// to the detector's own set until a selection has been stored, so a band is
|
||||
// never silently dropped before the settings have been read.
|
||||
func (a *App) bandOpenWanted(band string) bool {
|
||||
if !bandopen.Watched(band) {
|
||||
return false
|
||||
}
|
||||
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
|
||||
if !ok || len(sel) == 0 {
|
||||
return true
|
||||
}
|
||||
return sel[strings.ToLower(strings.TrimSpace(band))]
|
||||
}
|
||||
|
||||
// setBandOpenBands records the selection and puts out badges for bands that
|
||||
// have just been unticked — they fade on a timer fed by spots the detector no
|
||||
// longer looks at, so they would otherwise stay lit until the next restart.
|
||||
func (a *App) setBandOpenBands(bands []string) {
|
||||
sel := make(map[string]bool, len(bands))
|
||||
for _, b := range bands {
|
||||
sel[strings.ToLower(strings.TrimSpace(b))] = true
|
||||
}
|
||||
a.bandOpen.bands.Store(sel)
|
||||
|
||||
a.bandOpen.mu.Lock()
|
||||
defer a.bandOpen.mu.Unlock()
|
||||
for b := range a.bandOpen.live {
|
||||
if len(sel) > 0 && !sel[b] {
|
||||
delete(a.bandOpen.live, b)
|
||||
delete(a.bandOpen.aliveUntil, b)
|
||||
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearBandOpenings puts out every lit badge and forgets the detector's window.
|
||||
// Called when the watch is switched off: the badges fade on a timer fed by
|
||||
// spots the detector no longer looks at, so without this they would stay up
|
||||
// until the next restart. The remembered list is left alone — those openings
|
||||
// really did happen, and the operator may still want to see what he missed.
|
||||
func (a *App) clearBandOpenings() {
|
||||
a.bandOpen.mu.Lock()
|
||||
defer a.bandOpen.mu.Unlock()
|
||||
a.bandOpen.live = nil
|
||||
a.bandOpen.aliveUntil = nil
|
||||
// Drop the accumulated spot window too, so switching the watch back on starts
|
||||
// from what is on the air now rather than from an hour-old burst.
|
||||
a.bandOpen.det = nil
|
||||
}
|
||||
|
||||
// announceOpening logs and pushes one detection. Shared by both feeds — the
|
||||
// cluster path here and the PSK Reporter path in bandopen_sources.go — so an
|
||||
// opening reads the same however it was noticed.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/bandopen"
|
||||
"hamlog/internal/cluster"
|
||||
)
|
||||
|
||||
// The "Watch for band openings" switch has to gate the DETECTOR, not just the
|
||||
// extra data sources.
|
||||
//
|
||||
// It originally governed only the RBN nodes and the PSK Reporter feed, while
|
||||
// the detector itself ran on every ordinary cluster spot — so an operator who
|
||||
// had never enabled the watch still got opening banners for a feature he had
|
||||
// deliberately left off. That is what this pins.
|
||||
func TestBandOpenWatchGatesTheDetector(t *testing.T) {
|
||||
spot := func() cluster.Spot {
|
||||
return cluster.Spot{
|
||||
DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210,
|
||||
ReceivedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Switched off: the spot must not even reach the detector.
|
||||
off := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||
off.detectBandOpening(spot())
|
||||
if off.bandOpen.det != nil {
|
||||
t.Error("the detector ran with the watch switched off")
|
||||
}
|
||||
|
||||
// Switched on: the same spot is accepted (one spot is not an opening, so
|
||||
// nothing is announced — but the detector now exists and is collecting).
|
||||
on := &App{opSet: true, opLat: 48.0, opLon: 2.0}
|
||||
on.bandOpen.on.Store(true)
|
||||
on.detectBandOpening(spot())
|
||||
if on.bandOpen.det == nil {
|
||||
t.Error("the detector did not run with the watch switched on")
|
||||
}
|
||||
}
|
||||
|
||||
// Switching the watch off must put the badges out. They fade on a timer fed by
|
||||
// spots the detector no longer looks at, so left alone they would stay lit
|
||||
// until the next restart.
|
||||
func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
|
||||
a := &App{}
|
||||
a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}}
|
||||
a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)}
|
||||
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||
a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}}
|
||||
|
||||
a.clearBandOpenings()
|
||||
|
||||
if got := a.GetLiveOpenings(); len(got) != 0 {
|
||||
t.Errorf("a badge stayed lit after the watch was switched off: %v", got)
|
||||
}
|
||||
if a.bandOpen.det != nil {
|
||||
t.Error("the accumulated spot window survived — switching back on would start from a stale burst")
|
||||
}
|
||||
// The history is NOT cleared: those openings really happened.
|
||||
if len(a.GetBandOpenings()) != 1 {
|
||||
t.Error("the remembered openings were thrown away")
|
||||
}
|
||||
}
|
||||
|
||||
// Unticking a band in Settings must actually stop its announcements.
|
||||
//
|
||||
// Nothing checked the operator's selection on either feed path: bandopen.Watched
|
||||
// only says which bands the detector is CAPABLE of. So a station with just 6 m
|
||||
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
|
||||
// chasing widened the subscription to every band — from PSK Reporter too.
|
||||
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
|
||||
a := &App{opSet: true, opLat: 48, opLon: 2}
|
||||
a.bandOpen.on.Store(true)
|
||||
a.setBandOpenBands([]string{"6m"})
|
||||
|
||||
if !a.bandOpenWanted("6m") {
|
||||
t.Error("6m is ticked and was refused")
|
||||
}
|
||||
for _, b := range []string{"10m", "2m", "4m"} {
|
||||
if a.bandOpenWanted(b) {
|
||||
t.Errorf("%s is not ticked and was accepted", b)
|
||||
}
|
||||
}
|
||||
// A band the detector cannot watch at all stays out whatever is stored.
|
||||
if a.bandOpenWanted("20m") {
|
||||
t.Error("20m is not a watched band and was accepted")
|
||||
}
|
||||
|
||||
// And the cluster path must obey it too.
|
||||
a.detectBandOpening(cluster.Spot{
|
||||
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
|
||||
})
|
||||
if a.bandOpen.det != nil {
|
||||
t.Error("a spot on an unticked band reached the detector")
|
||||
}
|
||||
}
|
||||
|
||||
// Unticking a band puts its badge out. They fade on a timer fed by spots the
|
||||
// detector no longer looks at, so it would otherwise stay lit until a restart.
|
||||
func TestUntickingABandClearsItsBadge(t *testing.T) {
|
||||
a := &App{}
|
||||
a.bandOpen.live = map[string]bandopen.Opening{
|
||||
"6m": {Band: "6m", Calls: 9},
|
||||
"10m": {Band: "10m", Calls: 5},
|
||||
}
|
||||
a.bandOpen.aliveUntil = map[string]time.Time{
|
||||
"6m": time.Now().Add(time.Hour),
|
||||
"10m": time.Now().Add(time.Hour),
|
||||
}
|
||||
a.setBandOpenBands([]string{"6m"})
|
||||
|
||||
live := a.GetLiveOpenings()
|
||||
if len(live) != 1 || live[0].Band != "6m" {
|
||||
t.Errorf("live openings = %v, want only 6m", live)
|
||||
}
|
||||
}
|
||||
+99
-1
@@ -1,4 +1,102 @@
|
||||
[
|
||||
{
|
||||
"version": "0.25.0",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.",
|
||||
"DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps.",
|
||||
"Call lookup: QRZ.com now fills Name with the first name only, and optionally with the operator nickname instead.",
|
||||
"Entry form: a narrower left column, State beside the locator, and a new line with County, CQ, ITU and DXCC.",
|
||||
"Station Control: the short and long path headings are repeated under the compass, at a readable size and clickable.",
|
||||
"Compact mode: the window now fits the entry strip instead of leaving a band of empty space below it.",
|
||||
"HRDLog: an ON AIR option publishes your live frequency, mode and rig on hrdlog.net.",
|
||||
"ADIF: the county is exported as STATE,COUNTY as the standard requires, and a county imported that way now fills the state too.",
|
||||
"A /MM or /AM callsign no longer resolves to a country — RI1FJL/MM read as Franz Josef Land while the expedition was still sailing there."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.",
|
||||
"Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps.",
|
||||
"Recherche d indicatif : QRZ.com remplit désormais Nom avec le seul prénom, et au choix avec le surnom de l opérateur.",
|
||||
"Saisie : colonne de gauche plus étroite, État à côté du locator, et une nouvelle ligne Comté, CQ, ITU et DXCC.",
|
||||
"Contrôle station : les azimuts court et long chemin sont repris sous la boussole, lisibles et cliquables.",
|
||||
"Mode compact : la fenêtre épouse la bande de saisie au lieu de laisser une bande vide en dessous.",
|
||||
"HRDLog : une option ON AIR publie ta fréquence, ton mode et ta radio en direct sur hrdlog.net.",
|
||||
"ADIF : le comté est exporté sous la forme ÉTAT,COMTÉ comme l exige le standard, et un comté importé ainsi remplit aussi l état.",
|
||||
"Un indicatif en /MM ou /AM ne se résout plus en pays — RI1FJL/MM affichait Franz Josef Land alors que l expédition faisait encore route."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.",
|
||||
"The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.",
|
||||
"Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.",
|
||||
"LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded.",
|
||||
"FT8: a contact is logged with the grid the station actually sent, not the home square from QRZ — expeditions were logged in the wrong place.",
|
||||
"SPE amplifier: switching it off is shown at once, instead of needing a second press of OFF.",
|
||||
"Appearance: row colouring is now Worked / Confirmed / QSL sent / To be sent, each scoped to the channels you pick — paper, LoTW, eQSL, QRZ.com."
|
||||
],
|
||||
"fr": [
|
||||
"Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.",
|
||||
"La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.",
|
||||
"Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.",
|
||||
"LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés.",
|
||||
"FT8 : un contact est enregistré avec le locator réellement émis par la station, et non le carré du domicile depuis QRZ — les expéditions étaient logguées au mauvais endroit.",
|
||||
"Amplificateur SPE : l extinction s affiche immédiatement, au lieu de demander un second appui sur OFF.",
|
||||
"Apparence : la coloration des lignes devient Contacté / Confirmé / QSL envoyée / À envoyer, chacune limitée aux canaux choisis — papier, LoTW, eQSL, QRZ.com."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.8",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Cluster: the grid cache now holds 100,000 callsigns and rotates instead of emptying itself, so locators stop vanishing from the list.",
|
||||
"New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.",
|
||||
"Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.",
|
||||
"Band openings: unticking a band now actually stops its announcements, and puts its badge out.",
|
||||
"Callsign lookup: the website, postal code and HamQTH profile picture are now read — the QSO web column was never filled by any lookup.",
|
||||
"New selectable column \"QSO number\": position in the log, 1 for the oldest contact.",
|
||||
"New Appearance settings: colour whole log rows by QSL status, with your own colours from a palette.",
|
||||
"The QSO number was missing on contacts logged from WSJT-X — that path bypassed the numbering."
|
||||
],
|
||||
"fr": [
|
||||
"Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.",
|
||||
"Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.",
|
||||
"Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.",
|
||||
"Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge.",
|
||||
"Recherche d indicatif : le site web, le code postal et la photo de profil HamQTH sont désormais lus — la colonne web du QSO n était jamais remplie.",
|
||||
"Nouvelle colonne sélectionnable « Numéro de QSO » : position dans le log, 1 pour le contact le plus ancien.",
|
||||
"Nouveaux réglages Apparence : colorer les lignes entières du log selon le statut QSL, avec tes couleurs choisies dans une palette.",
|
||||
"Le numéro de QSO manquait sur les contacts enregistrés depuis WSJT-X — ce chemin contournait la numérotation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.7",
|
||||
"date": "",
|
||||
"en": [
|
||||
"QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones.",
|
||||
"Band openings: EME contacts are no longer mistaken for an opening. Each band now has the longest path the atmosphere can carry.",
|
||||
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency.",
|
||||
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio.",
|
||||
"Motorized antennas: tracking now offers three modes — every frequency change, past a step, or band change only.",
|
||||
"Motorized antennas: each covered band now has its own tune frequency, set in Settings.",
|
||||
"Awards: a single award can now be exported on its own.",
|
||||
"Awards: each reference now has its own validity window, so a reference that ceased to exist stops counting for later QSOs.",
|
||||
"Band openings: the watch switch now governs the detection itself, not just the extra data sources."
|
||||
],
|
||||
"fr": [
|
||||
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main.",
|
||||
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Chaque bande a désormais la distance maximale que l atmosphère peut porter.",
|
||||
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission.",
|
||||
"PowerGenius XL : la carte du Contrôle station affiche puissance, courant, ROS et température sans FlexRadio.",
|
||||
"Antennes motorisées : le suivi propose trois modes — à chaque changement de fréquence, au-delà d un pas, ou au changement de bande.",
|
||||
"Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, réglable dans les Réglages.",
|
||||
"Diplômes : un diplôme peut désormais être exporté seul.",
|
||||
"Diplômes : chaque référence a désormais sa fenêtre de validité, une référence disparue cesse donc de compter pour les QSO suivants.",
|
||||
"Ouvertures de bande : l interrupteur de la veille gouverne désormais la détection elle-même, plus seulement les sources de données."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.6",
|
||||
"date": "",
|
||||
@@ -1097,4 +1195,4 @@
|
||||
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/gridcache"
|
||||
)
|
||||
|
||||
// The grid cache used to drop EVERYTHING at its ceiling. That was survivable
|
||||
// while the only feed was this station's own decodes, which never reached it.
|
||||
// Fed by anything larger it is a cliff: every locator in the cluster list
|
||||
// disappears at once, periodically, and the operator sees the column empty
|
||||
// itself for no reason.
|
||||
//
|
||||
// Rotation keeps the previous generation, so a full cache costs the older half
|
||||
// and nothing more.
|
||||
func TestDecodeGridRotationKeepsThePreviousGeneration(t *testing.T) {
|
||||
a := &App{}
|
||||
|
||||
// Fill exactly one generation.
|
||||
for i := 0; i < decodeGridsCap; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Fatalf("first entry lost before any rotation: %q", got)
|
||||
}
|
||||
if a.decodeGridsOld != nil {
|
||||
t.Fatal("rotated early — the cap is the ceiling of ONE generation")
|
||||
}
|
||||
|
||||
// One more entry rotates.
|
||||
a.rememberDecodeGrid("NEWCALL", "IO91", gridcache.SourceDecode)
|
||||
if a.decodeGridsOld == nil {
|
||||
t.Fatal("did not rotate at the cap")
|
||||
}
|
||||
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||
t.Errorf("the entry that caused the rotation was lost: %q", got)
|
||||
}
|
||||
// The whole previous generation is still readable — this is the point.
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Errorf("a locator from the previous generation was dropped: %q — that is the cliff again", got)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL099999"); got != "JN36" {
|
||||
t.Errorf("previous generation incomplete: %q", got)
|
||||
}
|
||||
|
||||
// A second rotation is what finally retires the oldest half.
|
||||
for i := 0; i < decodeGridsCap; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("SECOND%06d", i), "KP20", gridcache.SourceDecode)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "" {
|
||||
t.Errorf("the cache is unbounded: %q survived two rotations", got)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("NEWCALL"); got != "IO91" {
|
||||
t.Errorf("an entry one generation old was retired too early: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Callsigns are normalised on the way in AND on the way out, or a spot for
|
||||
// "f4bpo" would miss a grid learnt as "F4BPO".
|
||||
func TestDecodeGridCaseAndBlanks(t *testing.T) {
|
||||
a := &App{}
|
||||
a.rememberDecodeGrid(" f4bpo ", "JN36", gridcache.SourceDecode)
|
||||
if got := a.lookupDecodeGrid("F4BPO"); got != "JN36" {
|
||||
t.Errorf("lookup of the upper-case form failed: %q", got)
|
||||
}
|
||||
a.rememberDecodeGrid("", "JN36", gridcache.SourceDecode)
|
||||
a.rememberDecodeGrid("K1ABC", "", gridcache.SourceDecode)
|
||||
if got := a.lookupDecodeGrid("K1ABC"); got != "" {
|
||||
t.Errorf("stored an empty grid: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The write path runs on the decode goroutine while the cluster status builder
|
||||
// reads. Rotation swaps the map headers, so an unguarded read is a data race
|
||||
// rather than a stale value.
|
||||
//
|
||||
// This build is CGO-free, so -race is not available here; the test exercises the
|
||||
// interleaving and would fault on a concurrent map access even without it.
|
||||
func TestDecodeGridConcurrentAccess(t *testing.T) {
|
||||
a := &App{}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20000; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("W%05d", i), "FN31", gridcache.SourceDecode)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20000; i++ {
|
||||
_ = a.lookupDecodeGrid(fmt.Sprintf("W%05d", i))
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Only a CHANGE may reach the write batch.
|
||||
//
|
||||
// The feeds repeat themselves — the same station is reported by dozens of
|
||||
// receivers a minute — so queueing every report would put the whole stream in
|
||||
// the batch instead of the news in it, and turn a cache into a write amplifier.
|
||||
func TestOnlyChangesAreQueued(t *testing.T) {
|
||||
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
a := &App{gridStore: st}
|
||||
|
||||
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||
if n := st.Pending(); n != 1 {
|
||||
t.Fatalf("a new locator queued %d writes, want 1", n)
|
||||
}
|
||||
if err := st.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The same report, a hundred times over, is not news.
|
||||
for i := 0; i < 100; i++ {
|
||||
a.rememberDecodeGrid("F4BPO", "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if n := st.Pending(); n != 0 {
|
||||
t.Errorf("unchanged reports queued %d writes — the batch would carry the whole feed", n)
|
||||
}
|
||||
|
||||
// A station that moved is.
|
||||
a.rememberDecodeGrid("F4BPO", "KP30", gridcache.SourceDecode)
|
||||
if n := st.Pending(); n != 1 {
|
||||
t.Errorf("a changed locator queued %d writes, want 1", n)
|
||||
}
|
||||
if got := a.lookupDecodeGrid("F4BPO"); got != "KP30" {
|
||||
t.Errorf("the map kept the old locator: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation must be OFF while a store is attached: it would discard callsigns the
|
||||
// database still holds, and the lookup would then miss something we know.
|
||||
func TestNoRotationWhilePersisting(t *testing.T) {
|
||||
st, err := gridcache.Open(filepath.Join(t.TempDir(), "grids.db"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
a := &App{gridStore: st}
|
||||
|
||||
for i := 0; i < decodeGridsCap+10; i++ {
|
||||
a.rememberDecodeGrid(fmt.Sprintf("CALL%06d", i), "JN36", gridcache.SourceDecode)
|
||||
}
|
||||
if a.decodeGridsOld != nil {
|
||||
t.Error("rotated while a store was attached — locators the database holds would go missing")
|
||||
}
|
||||
if got := a.lookupDecodeGrid("CALL000000"); got != "JN36" {
|
||||
t.Errorf("the first entry was dropped: %q", got)
|
||||
}
|
||||
}
|
||||
+177
-20
@@ -16,7 +16,7 @@ import {
|
||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||
SMTPConfigured, SendLogToDeveloper,
|
||||
WorkedBefore,
|
||||
SetCompactMode,
|
||||
SetCompactMode, SetCompactHeight,
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
@@ -90,7 +90,8 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||
import { GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||
@@ -528,6 +529,28 @@ export default function App() {
|
||||
setCompact(next);
|
||||
SetCompactMode(next);
|
||||
}
|
||||
// Fit the compact window to what was actually rendered.
|
||||
//
|
||||
// The height was a constant "tuned so the compact entry strip fits in a single
|
||||
// row". A constant tuned against a layout stops being true the moment the
|
||||
// layout changes, and this one outlived a strip that had since lost a row —
|
||||
// leaving a band of empty window under the fields. Measure instead.
|
||||
const compactRootRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
const fit = () => {
|
||||
const el = compactRootRef.current;
|
||||
if (!el) return;
|
||||
// Rounded, so a sub-pixel reflow cannot start a resize loop.
|
||||
// + the compact topbar, which is a fixed h-8 (32px), + a couple of pixels
|
||||
// for the border so the strip is never clipped by one row of anti-aliasing.
|
||||
SetCompactHeight(Math.round(el.getBoundingClientRect().height) + 32 + 2);
|
||||
};
|
||||
const id = window.setTimeout(fit, 60); // let the strip paint first
|
||||
const ro = new ResizeObserver(fit);
|
||||
if (compactRootRef.current) ro.observe(compactRootRef.current);
|
||||
return () => { window.clearTimeout(id); ro.disconnect(); };
|
||||
}, [compact]);
|
||||
|
||||
// CAT — receives live rig state via Wails events.
|
||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||
@@ -1497,8 +1520,11 @@ export default function App() {
|
||||
// beside Hide worked and not among the status chips.
|
||||
const [clusterLotwOnly, setClusterLotwOnly] = useState(() => localStorage.getItem('opslog.clusterLotwOnly') === '1');
|
||||
const [clusterSpotterConts, setClusterSpotterConts] = useState<Set<string>>(() => lsSet<string>('opslog.clusterSpotterCont'));
|
||||
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => localStorage.getItem('opslog.clusterMuteWorked') === '1');
|
||||
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => localStorage.getItem('opslog.clusterSlotHighlight') === '1');
|
||||
// Read through lib/spotDisplay, not straight from localStorage: while the two
|
||||
// options are withdrawn it answers false, so the cluster list cannot end up
|
||||
// applying an option the operator can no longer see or switch off.
|
||||
const [clusterMuteWorked, setClusterMuteWorked] = useState(() => readSpotDisplayOptions().muteWorked);
|
||||
const [clusterSlotHighlight, setClusterSlotHighlight] = useState(() => readSpotDisplayOptions().slotHighlight);
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
@@ -1920,6 +1946,35 @@ export default function App() {
|
||||
// change). The grid reads them straight from the row — no per-page backend
|
||||
// recompute — so here we just parse the stored JSON string into the code→ref
|
||||
// object the award columns expect (keys are already upper-case).
|
||||
// Row colouring by QSL status (Settings → Appearance). Reloaded when the
|
||||
// settings dialog closes, which is the only place it changes.
|
||||
const [rowColors, setRowColors] = useState<any>(null);
|
||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
|
||||
// than filtered at render: the cluster list, every band map and the counts all
|
||||
// read the same array, so pruning it once is what makes the setting mean the
|
||||
// same thing everywhere — and it gives the memory back.
|
||||
const [spotTTLMin, setSpotTTLMin] = useState(0);
|
||||
useEffect(() => { GetSpotTTLMinutes().then(setSpotTTLMin).catch(() => {}); }, [showSettings]);
|
||||
useEffect(() => {
|
||||
if (spotTTLMin <= 0) return; // 0 = keep until the count cap pushes them out
|
||||
const sweep = () => {
|
||||
const cutoff = Date.now() - spotTTLMin * 60_000;
|
||||
setSpots((arr) => {
|
||||
const kept = arr.filter((sp) => {
|
||||
const t = Date.parse((sp as any).received_at ?? '');
|
||||
return isNaN(t) || t >= cutoff; // an unparseable stamp is never a reason to drop a spot
|
||||
});
|
||||
return kept.length === arr.length ? arr : kept;
|
||||
});
|
||||
};
|
||||
sweep();
|
||||
// Half a minute: fine enough that a 5-minute setting is honoured closely,
|
||||
// coarse enough to be invisible next to the spot stream itself.
|
||||
const id = window.setInterval(sweep, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [spotTTLMin]);
|
||||
|
||||
const qsosWithAwards = useMemo(
|
||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
|
||||
[qsos],
|
||||
@@ -3300,7 +3355,7 @@ export default function App() {
|
||||
sat_name: details.sat_name, sat_mode: details.sat_mode,
|
||||
contest_id: details.contest_id,
|
||||
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
|
||||
email: details.email,
|
||||
email: details.email, web: details.web,
|
||||
};
|
||||
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
|
||||
// Contest mode overrides the exchange fields: CONTEST_ID, the sent
|
||||
@@ -3663,6 +3718,7 @@ export default function App() {
|
||||
ituz: d.ituz ?? (last.ituz || undefined),
|
||||
cont: d.cont || last.cont || '',
|
||||
email: d.email || last.email || '',
|
||||
web: d.web || last.web || '',
|
||||
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||
};
|
||||
});
|
||||
@@ -3725,6 +3781,7 @@ export default function App() {
|
||||
ituz: d.ituz ?? (r.ituz || undefined),
|
||||
cont: d.cont || (r.cont ?? ''),
|
||||
email: d.email || (r.email ?? ''),
|
||||
web: d.web || (r.web ?? ''),
|
||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||
}));
|
||||
// Backfill anything the provider didn't supply from the last time we worked
|
||||
@@ -4404,6 +4461,67 @@ export default function App() {
|
||||
onBlur={() => setQth(titleCase)} />
|
||||
</div>
|
||||
);
|
||||
// State sits between QTH and the locator: it is part of the same "where is
|
||||
// this station" reading, and it is what several awards key off (WAS, the US
|
||||
// county work). Narrow — it holds two or three characters.
|
||||
const stateBlock = (
|
||||
<div className="flex flex-col w-[70px] shrink-0"><Label className="mb-1 h-3.5">{t('field.state')}</Label>
|
||||
<Input value={details.state ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, state: e.target.value.toUpperCase() }))} />
|
||||
</div>
|
||||
);
|
||||
// County beside State: the lookup fills QTH, State, County and the locator
|
||||
// together, and they are read together. Wider than State because a county name
|
||||
// is a name — "St. Tammany Parish", not a code.
|
||||
// The row Comment/Note freed by moving onto one line: county, zones and the
|
||||
// ADIF entity number. All four are filled by the lookup and were previously
|
||||
// only visible after opening a tab — which is the wrong place for values an
|
||||
// operator wants to SEE are right before pressing Log.
|
||||
//
|
||||
// A numeric field stores undefined when cleared, not 0: zero is a real DXCC
|
||||
// number for "no entity" and a real answer for a zone, so an empty box must
|
||||
// stay empty rather than assert something.
|
||||
// NEW badge on the county — the same answer the Details tab gives, moved to
|
||||
// where the county is now typed. Debounced: this field is typed into as well
|
||||
// as filled by the lookup, and the check hits the database.
|
||||
const [entryNewCounty, setEntryNewCounty] = useState(false);
|
||||
useEffect(() => {
|
||||
const cnty = (details.cnty ?? '').trim();
|
||||
if (!cnty) { setEntryNewCounty(false); return; }
|
||||
let alive = true;
|
||||
const id = window.setTimeout(async () => {
|
||||
try {
|
||||
const isNew = await IsNewUSCounty(details.state ?? '', cnty);
|
||||
if (alive) setEntryNewCounty(!!isNew);
|
||||
} catch { if (alive) setEntryNewCounty(false); }
|
||||
}, 300);
|
||||
return () => { alive = false; window.clearTimeout(id); };
|
||||
}, [details.state, details.cnty]);
|
||||
|
||||
const num = (v: string): number | undefined => {
|
||||
const s = v.replace(/[^0-9]/g, '');
|
||||
return s === '' ? undefined : parseInt(s, 10);
|
||||
};
|
||||
const geoDetailRow = (
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex flex-col flex-1 min-w-0"><Label className="mb-1 h-3.5">{t('field.cnty')}</Label>
|
||||
<Input value={details.cnty ?? ''}
|
||||
onChange={(e) => setDetails((d) => ({ ...d, cnty: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.cqz')}</Label>
|
||||
<Input value={details.cqz ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, cqz: num(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[64px] shrink-0"><Label className="mb-1 h-3.5">{t('field.ituz')}</Label>
|
||||
<Input value={details.ituz ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, ituz: num(e.target.value) }))} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[72px] shrink-0"><Label className="mb-1 h-3.5">{t('field.dxcc')}</Label>
|
||||
<Input value={details.dxcc ?? ''} className="font-mono"
|
||||
onChange={(e) => setDetails((d) => ({ ...d, dxcc: num(e.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const gridBlock = (
|
||||
<div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
|
||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||
@@ -4707,12 +4825,21 @@ export default function App() {
|
||||
// Cluster spots after every active filter (band / mode / status / search /
|
||||
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster
|
||||
// pane so both show exactly the same list.
|
||||
const clusterRenderedRows = useMemo(() => {
|
||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||
// Everything the operator has said about WHICH STATIONS they care about —
|
||||
// LoTW only, the spotter's continent, hide worked, the status and mode chips,
|
||||
// the search box, the source node.
|
||||
//
|
||||
// Shared with the band map, which used to receive the raw stream filtered by
|
||||
// band alone: switching on "LoTW users only" changed the cluster list and left
|
||||
// the panadapter showing everyone. Two views of one spot stream disagreeing
|
||||
// about the same spot is the fault lib/spotDisplay already exists to prevent.
|
||||
//
|
||||
// The BAND filter is deliberately not in here: a band map's band IS its
|
||||
// filter, and applying the cluster's would empty every map but one.
|
||||
const spotPassesStationFilters = useCallback((s: ClusterSpot): boolean => {
|
||||
const search = clusterSearch.trim().toUpperCase();
|
||||
const list = spots.filter((s) => {
|
||||
{
|
||||
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
if (search && !s.dx_call.includes(search)) return false;
|
||||
if (clusterLockMode) {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
@@ -4761,9 +4888,27 @@ export default function App() {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const e = spotStatus[k];
|
||||
if (!e) return false;
|
||||
if (e.worked_call || e.status === 'worked') return false;
|
||||
// A spot that is NEW for something is not "worked", whatever the entity
|
||||
// says. The extra markers are orthogonal to the entity status — the same
|
||||
// rule the band map already states — so a new prefix, county, grid or
|
||||
// park in an entity worked years ago is exactly what an operator is
|
||||
// hunting, and hiding it is the opposite of what was asked.
|
||||
//
|
||||
// Reported on BH2SWB and 4X9AA: both flagged NEW PFX, neither ever
|
||||
// worked, both vanished the moment "hide worked" went on.
|
||||
const isNew = !!(e.new_pota || e.new_county || e.new_pfx || e.new_grid);
|
||||
if (!isNew && (e.worked_call || e.status === 'worked')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}, [clusterFilterSource, clusterSearch, clusterLockMode, mode, clusterModeFilter,
|
||||
clusterStatusFilter, spotStatus, clusterLotwOnly, clusterSpotterConts, clusterHideWorked]);
|
||||
|
||||
const clusterRenderedRows = useMemo(() => {
|
||||
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
|
||||
const list = spots.filter((s) => {
|
||||
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
|
||||
return spotPassesStationFilters(s);
|
||||
});
|
||||
let rendered = list as (ClusterSpot & { repeats?: number })[];
|
||||
if (clusterGroup) {
|
||||
@@ -4843,8 +4988,11 @@ export default function App() {
|
||||
<div className="space-y-0.5">
|
||||
{fSwitch(t('clu.hideWorked'), clusterHideWorked, setClusterHideWorked)}
|
||||
{fSwitch(t('clu.groupDup'), clusterGroup, setClusterGroup)}
|
||||
{fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
||||
{fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
||||
{/* The two display options are withdrawn for now — the flag is in
|
||||
lib/spotDisplay, and it also forces them off for the band map, so
|
||||
there is one place to flip when they come back. */}
|
||||
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.muteWorkedShort'), clusterMuteWorked, (v) => { setClusterMuteWorked(v); writeUiPref('opslog.clusterMuteWorked', v ? '1' : '0'); })}
|
||||
{SPOT_DISPLAY_OPTIONS_EXPOSED && fSwitch(t('clu.slotHighlightShort'), clusterSlotHighlight, (v) => { setClusterSlotHighlight(v); writeUiPref('opslog.clusterSlotHighlight', v ? '1' : '0'); })}
|
||||
{fSwitch(t('clu.lotwOnly'), clusterLotwOnly, (v) => { setClusterLotwOnly(v); writeUiPref('opslog.clusterLotwOnly', v ? '1' : '0'); })}
|
||||
</div>
|
||||
|
||||
@@ -5062,6 +5210,7 @@ export default function App() {
|
||||
// rewrote the other's widths.
|
||||
storageKey="mainpane"
|
||||
rows={qsosWithAwards as any}
|
||||
rowColors={rowColors}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -5765,7 +5914,7 @@ export default function App() {
|
||||
Enter from any <input> inside the strip logs the QSO. Radix Selects
|
||||
render as <button> elements and are ignored by this handler — they
|
||||
keep their own keyboard behaviour. */}
|
||||
<div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<div ref={compactRootRef} className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
|
||||
<section
|
||||
className={cn('bg-card shadow-sm border-border',
|
||||
compact
|
||||
@@ -5820,25 +5969,30 @@ export default function App() {
|
||||
to be pushed outside the entry panel and clipped. Wrapping drops
|
||||
it to its own line instead, which stays readable. */}
|
||||
<div className="flex gap-4 items-end flex-wrap">
|
||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||
<div className="flex flex-col w-[230px] shrink-0"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
|
||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }}
|
||||
onBlur={() => setName(titleCase)} />
|
||||
</div>
|
||||
{qthBlock}
|
||||
{stateBlock}
|
||||
{gridBlock}
|
||||
</div>
|
||||
|
||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||
single-line Comment/Note on the right. */}
|
||||
Comment/Note SIDE BY SIDE on the right — one line each was a lot
|
||||
of vertical space for two fields that are usually short. */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="flex flex-col gap-1.5 w-[300px] shrink-0">
|
||||
<div className="flex flex-col gap-1.5 w-[230px] shrink-0">
|
||||
{bandRow}
|
||||
{modeRow}
|
||||
{countryRow}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
{commentLine}
|
||||
{noteLine}
|
||||
{geoDetailRow}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 min-w-0">{commentLine}</div>
|
||||
<div className="flex-1 min-w-0">{noteLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6345,6 +6499,7 @@ export default function App() {
|
||||
<RecentQSOsGrid
|
||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||
rows={qsosWithAwards as any}
|
||||
rowColors={rowColors}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
awardCols={awardCols}
|
||||
@@ -6760,13 +6915,14 @@ export default function App() {
|
||||
</div>
|
||||
<BandMap
|
||||
band={b}
|
||||
spots={spots.filter((s) => s.band === b)}
|
||||
spots={spots.filter((s) => s.band === b && spotPassesStationFilters(s))}
|
||||
spotStatus={spotStatus}
|
||||
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||
onSpotClick={handleSpotClick}
|
||||
onClose={() => toggleBandMapBand(b)}
|
||||
hideDigital={bandMapHideFt}
|
||||
fitToBand={bandMapFit}
|
||||
showLotw={!!rowColors?.bandmap_lotw}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -6796,11 +6952,12 @@ export default function App() {
|
||||
side={bandMapSide}
|
||||
onToggleSide={toggleBandMapSide}
|
||||
band={band}
|
||||
spots={spots.filter((s) => s.band === band)}
|
||||
spots={spots.filter((s) => s.band === band && spotPassesStationFilters(s))}
|
||||
spotStatus={spotStatus}
|
||||
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||
onSpotClick={handleSpotClick}
|
||||
onClose={() => setBandMapShown(false)}
|
||||
showLotw={!!rowColors?.bandmap_lotw}
|
||||
keyNav
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +174,45 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
|
||||
// Meters built from the amplifier's own GSCP status frame, for when the radio
|
||||
// is not feeding a meter stream.
|
||||
//
|
||||
// Whether there is power is the amp's state field; how much is the plain
|
||||
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
|
||||
// reset and survives in the last-known status after the amp disconnects, so it
|
||||
// once claimed 1350 W from an old transmission while 10 W was going out. Same
|
||||
// reason peak_id is left alone. Both readings are gated on transmit so they
|
||||
// fall back to zero between overs instead of freezing on the last one.
|
||||
const pgxlMeters = () => {
|
||||
if (!pg.connected) return null;
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
|
||||
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
|
||||
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
|
||||
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
|
||||
const tempC = Number(pg.temperature) || 0;
|
||||
// Two columns, not four. The card sits beside a tall neighbour in Station
|
||||
// Control, so a single row of four leaves the height empty and squeezes each
|
||||
// bar into a quarter width — two rows of two use the room that is already
|
||||
// there and give every bar twice the resolution.
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||
display={`${Math.round(fwdW)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
|
||||
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
|
||||
rather than a zero that looks like a perfect match. */}
|
||||
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
|
||||
display={swr >= 1 ? swr.toFixed(1) : '—'}
|
||||
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
|
||||
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
|
||||
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
|
||||
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
@@ -204,13 +243,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
|
||||
{viaFlex && (() => {
|
||||
{/* Amplifier meters (FWD / ID / TEMP …).
|
||||
The FlexRadio UDP stream is the preferred source — it is fast and reads
|
||||
the same as SmartSDR. When there is no Flex, or it is not streaming,
|
||||
the amplifier's OWN link carries the same figures; falling back to them
|
||||
is what the docked widget already does. Without that fallback this card
|
||||
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
|
||||
while the amplifier was reporting power, current and temperature all
|
||||
along. */}
|
||||
{(() => {
|
||||
const meters = (flex?.meters as any[]) || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.length === 0) return null;
|
||||
if (!viaFlex || amps.length === 0) return pgxlMeters();
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake twice
|
||||
// over: it is a latched maximum that is never reset, and it survives in
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RowColorSettings } from '@/lib/rowColors';
|
||||
|
||||
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||
// an error state rather than a status.
|
||||
const PALETTE = [
|
||||
'#16a34a', '#0ea5e9', '#f59e0b', '#a855f7',
|
||||
'#dc2626', '#14b8a6', '#eab308', '#ec4899',
|
||||
'#64748b', '#84cc16', '#6366f1', '#f97316',
|
||||
];
|
||||
|
||||
// The rule ids the backend orders; the labels live here so a translation never
|
||||
// travels through the settings row.
|
||||
const LABELS: Record<string, string> = {
|
||||
to_send: 'appr.ruleToSend',
|
||||
confirmed: 'appr.ruleConfirmed',
|
||||
sent: 'appr.ruleSent',
|
||||
worked: 'appr.ruleWorked',
|
||||
};
|
||||
|
||||
// The channels a rule can be scoped to. "worked" is the catch-all — it means
|
||||
// nothing on ANY channel — so narrowing it would say nothing.
|
||||
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||
};
|
||||
|
||||
export function AppearancePanel() {
|
||||
const { t } = useI18n();
|
||||
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setCfg((await GetRowColors()) as any); } catch { /* defaults on the backend */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const save = (next: RowColorSettings) => {
|
||||
setCfg(next);
|
||||
SaveRowColors(next as any).catch(() => {});
|
||||
};
|
||||
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
|
||||
if (!cfg) return;
|
||||
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
||||
};
|
||||
|
||||
// The rule card shows the row exactly as the grid will draw it, so the choice
|
||||
// is made by looking rather than by imagining.
|
||||
const preview = (color: string): Record<string, string> => {
|
||||
const st = cfg?.style ?? 'bar';
|
||||
const pct = cfg?.intensity ?? 12;
|
||||
const out: Record<string, string> = {};
|
||||
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
|
||||
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
|
||||
return out;
|
||||
};
|
||||
|
||||
if (!cfg) return <div className="p-1 text-sm text-muted-foreground">…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!cfg.bandmap_lotw} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, bandmap_lotw: !!c } as any)} />
|
||||
<span>{t('appr.bandmapLotw')} <span className="text-xs text-muted-foreground">{t('appr.bandmapLotwHint')}</span></span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={cfg.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
|
||||
<span>{t('appr.enable')} <span className="text-xs text-muted-foreground">{t('appr.enableHint')}</span></span>
|
||||
</label>
|
||||
|
||||
{cfg.enabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Style first: it decides whether the colours below are a signal or a
|
||||
wallpaper, which matters more than which hue they are. */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm">{t('appr.style')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||
{(['bar', 'tint', 'both'] as const).map((v) => (
|
||||
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
|
||||
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(cfg.style ?? 'bar') !== 'bar' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
{t('appr.intensity')}
|
||||
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
|
||||
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
|
||||
className="w-32 accent-[var(--primary)]" />
|
||||
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{/* Order matters and is shown: a contact is usually several of these at
|
||||
once, and the first match wins. */}
|
||||
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
|
||||
{cfg.rules.map((r, i) => (
|
||||
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
|
||||
style={r.enabled ? preview(r.color) : undefined}>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
|
||||
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
|
||||
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
|
||||
</label>
|
||||
{/* Which channels this category looks at. None ticked = all of
|
||||
them, which is what an unnarrowed rule should mean. */}
|
||||
{r.enabled && r.id !== 'worked' && (
|
||||
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
|
||||
{CHANNELS.map((c) => {
|
||||
const on = !r.channels?.length || r.channels.includes(c);
|
||||
return (
|
||||
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox checked={on} onCheckedChange={(v) => {
|
||||
const cur = r.channels?.length ? r.channels : [...CHANNELS];
|
||||
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
|
||||
patchRule(r.id, { channels: next });
|
||||
}} />
|
||||
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{r.enabled && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap pl-6">
|
||||
{PALETTE.map((c) => (
|
||||
<button key={c} type="button" title={c}
|
||||
onClick={() => patchRule(r.id, { color: c })}
|
||||
className={cn('size-6 rounded-md border-2 transition-transform hover:scale-110',
|
||||
r.color.toLowerCase() === c ? 'border-foreground' : 'border-transparent')}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
<input type="color" value={r.color} title={t('appr.custom')}
|
||||
onChange={(e) => patchRule(r.id, { color: e.target.value })}
|
||||
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
|
||||
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle, Share2 } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
ExportAwardForCatalog, ExportAward,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -97,6 +97,13 @@ function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onTog
|
||||
);
|
||||
}
|
||||
|
||||
// Awards use a far-future date as "no end" (RDA carries 9999-12-31). Printing it
|
||||
// back at the operator reads like a real deadline, so show it as open-ended.
|
||||
function openEnded(d?: string): string {
|
||||
if (!d) return '—';
|
||||
return /^9\d{3}-/.test(d) ? '—' : d;
|
||||
}
|
||||
|
||||
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
|
||||
@@ -307,6 +314,23 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export the SELECTED award on its own — the unit you actually share. The
|
||||
// bundle above is a backup: sending it to someone hands over your whole
|
||||
// catalogue when they asked for one award.
|
||||
//
|
||||
// Distinct from the catalog publish below, which is for shipping an award INTO
|
||||
// OpsLog: that one stamps a version and clears user_edited. This one is a plain
|
||||
// share and leaves both alone, so the recipient's copy is correctly marked as
|
||||
// someone else's work rather than as a pristine built-in.
|
||||
async function exportOne() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
try {
|
||||
const code = cur.code.trim().toUpperCase();
|
||||
const p = await ExportAward(code);
|
||||
if (p) setErr(t('awed.exportedOneTo', { code, path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
@@ -761,6 +785,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<TabsContent value="refs" className="mt-0">
|
||||
<ReferencesPanel
|
||||
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
|
||||
awardValidFrom={cur.valid_from} awardValidTo={cur.valid_to}
|
||||
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
|
||||
onChanged={loadMeta} setErr={setErr}
|
||||
/>
|
||||
@@ -780,6 +805,14 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.export')}
|
||||
</Button>
|
||||
{/* Share just the selected one. Sitting next to the whole-catalogue
|
||||
export because that is where an operator looks for it, and labelled
|
||||
with the code so the two are never confused at a glance. */}
|
||||
{cur && (
|
||||
<Button variant="outline" onClick={exportOne} title={t('awed.exportOneTitle')}>
|
||||
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur.code.trim().toUpperCase() })}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||
</Button>
|
||||
@@ -874,8 +907,8 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
// ReferencesPanel — manage the reference list of one award: search/list on the
|
||||
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
|
||||
// the online updater (POTA/SOTA/WWFF).
|
||||
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta;
|
||||
function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, onUpdateOnline, updating, onChanged, setErr }: {
|
||||
code: string; presets: Preset[]; meta?: RefMeta; awardValidFrom?: string; awardValidTo?: string;
|
||||
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -1031,6 +1064,26 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
||||
third-party list may carry the values — but they are not offered
|
||||
for editing until something actually reads them. */}
|
||||
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
|
||||
{/* This reference's own validity window. A reference is not forever:
|
||||
a park is delisted, a district merged. A QSO made while it
|
||||
existed still counts — it was a valid contact on the day — and
|
||||
one made afterwards does not.
|
||||
Left empty the award's own dates govern, which is why they show
|
||||
as the placeholder: the operator can see what "empty" inherits
|
||||
instead of having to remember. */}
|
||||
<Field2 label={t('awed.refValidFrom')}>
|
||||
<Input type="date" className="h-8 w-44" value={sel.valid_from ?? ''}
|
||||
onChange={(e) => patchSel({ valid_from: e.target.value })} />
|
||||
</Field2>
|
||||
<Field2 label={t('awed.refValidTo')}>
|
||||
<Input type="date" className="h-8 w-44" value={sel.valid_to ?? ''}
|
||||
onChange={(e) => patchSel({ valid_to: e.target.value })} />
|
||||
</Field2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(awardValidFrom || awardValidTo)
|
||||
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||
: t('awed.refValidHint')}
|
||||
</p>
|
||||
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,9 @@ type SpotStatusEntry = {
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
// Whether the station uploads to LoTW. Not a status — it says nothing about
|
||||
// whether the spot is worth chasing — so it is drawn as a badge, never a colour.
|
||||
lotw?: boolean;
|
||||
};
|
||||
|
||||
// The extra markers are ORTHOGONAL to the entity status: a spot can be a worked
|
||||
@@ -79,6 +82,8 @@ interface Props {
|
||||
// globally from the band-map tab toolbar.
|
||||
hideDigital?: boolean;
|
||||
fitToBand?: boolean;
|
||||
// Mark stations that upload to LoTW (Settings → Appearance).
|
||||
showLotw?: boolean;
|
||||
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||
@@ -227,7 +232,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
||||
// last; ties broken by closeness to the rig freq).
|
||||
const MAX_VISIBLE_SPOTS = 30;
|
||||
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||||
export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false, showLotw = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
// The two display options are applied ONCE here, on the whole map, so the
|
||||
@@ -649,6 +654,15 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
||||
})()}
|
||||
<span className="flex items-center gap-1.5 px-2 font-mono text-[11px] font-bold leading-none">
|
||||
<span>{p.spot.dx_call}</span>
|
||||
{/* Same badge the cluster list draws, for the same reason: the
|
||||
muted-blue of a confirmation, never a status colour —
|
||||
whether a station uploads to LoTW says nothing about
|
||||
whether the spot is worth chasing. A glyph rather than a
|
||||
colour also keeps it off the channel the statuses use. */}
|
||||
{showLotw && entry?.lotw && (
|
||||
<span title={t('clu.lotwBadge')} className="text-[9px] font-normal rounded px-1 py-px"
|
||||
style={{ background: 'color-mix(in srgb, var(--info) 22%, transparent)', color: 'var(--info)' }}>L</span>
|
||||
)}
|
||||
{mode && (
|
||||
<span className="text-[9px] font-normal text-current/70 bg-current/10 rounded px-1 py-px">
|
||||
{mode}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface DetailsState {
|
||||
srx_string?: string;
|
||||
stx_string?: string;
|
||||
email: string;
|
||||
web?: string;
|
||||
// Award references for the contacted station (set via the Awards tab picker).
|
||||
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
|
||||
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
|
||||
@@ -373,12 +374,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('detp.qslMessage')} span={7}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslVia')} span={5}>
|
||||
{/* QSL via gets the room, not the message. Width should follow use, and
|
||||
these two are nowhere near equal: a manager's callsign is filled in
|
||||
constantly and a QSL message almost never. The message had 7 columns
|
||||
of 12 for text most operators never type. */}
|
||||
<Field label={t('detp.qslVia')} span={7}>
|
||||
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.qslMessage')} span={5}>
|
||||
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -494,8 +499,14 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
|
||||
<Field label="STX">
|
||||
<Input value={details.stx_string ?? ''} placeholder={t('detp.sentExchangePh')} onChange={(e) => onChange({ stx_string: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.contactedEmail')} span={3}>
|
||||
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
|
||||
{/* No placeholder: a greyed-out sample address in an empty field reads
|
||||
as a value the lookup found, and that is exactly the field an
|
||||
operator checks to see whether it found one. */}
|
||||
<Field label={t('detp.contactedEmail')} span={2}>
|
||||
<Input value={details.email} onChange={(e) => onChange({ email: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('detp.contactedWeb')}>
|
||||
<Input value={details.web ?? ''} onChange={(e) => onChange({ web: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||
import { rowStyleFor, type RowColorSettings } from '@/lib/rowColors';
|
||||
|
||||
// Register every Community feature once. v32+ requires explicit registration;
|
||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||
@@ -80,6 +81,8 @@ type Props = {
|
||||
// One column per defined award; the cell shows the reference this QSO counts
|
||||
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||
awardCols?: { code: string; name: string }[];
|
||||
// Whole-row colouring by QSL / LoTW status (Settings → Appearance).
|
||||
rowColors?: RowColorSettings | null;
|
||||
};
|
||||
|
||||
const BASE_COLSTATE_KEY = 'hamlog.qsoColState.v2';
|
||||
@@ -132,6 +135,10 @@ function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||
|
||||
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||
// ── QSO basics ──
|
||||
// Position in the log, oldest = 1. Filled by the backend over the WHOLE log,
|
||||
// so it does not change when a filter narrows what is shown.
|
||||
{ group: 'QSO', label: t('rqg.c.number'), colId: 'number', headerName: t('rqg.h.number'), field: 'number' as any, width: 80, type: 'rightAligned', cellClass: 'font-mono',
|
||||
comparator: (a, b) => (a ?? 0) - (b ?? 0) },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||
{ group: 'QSO', label: t('rqg.c.callsign'), colId: 'callsign', headerName: t('rqg.c.callsign'), field: 'callsign' as any, width: 110, cellClass: 'font-mono font-semibold', defaultVisible: true },
|
||||
@@ -299,7 +306,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
||||
return rest;
|
||||
});
|
||||
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||
const { t } = useI18n();
|
||||
const gridRef = useRef<any>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -696,6 +703,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
||||
animateRows={false}
|
||||
suppressCellFocus
|
||||
getRowId={(p) => String((p.data as any).id)}
|
||||
getRowStyle={(p) => rowStyleFor(p.data, rowColors ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,9 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center 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 p-2 min-h-0">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||
@@ -188,6 +190,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
); })}
|
||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||
</svg>
|
||||
|
||||
{/* 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.
|
||||
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. */}
|
||||
<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 ' +
|
||||
(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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -77,6 +77,7 @@ import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'
|
||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||
import { OperatingPanel } from '@/components/OperatingPanel';
|
||||
import { AppearancePanel } from '@/components/AppearancePanel';
|
||||
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
|
||||
|
||||
type LookupSettings = LookupSettingsForm;
|
||||
@@ -176,6 +177,7 @@ interface Props {
|
||||
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||
type SectionId =
|
||||
| 'general'
|
||||
| 'appearance'
|
||||
| 'email'
|
||||
| 'station'
|
||||
| 'profiles'
|
||||
@@ -266,6 +268,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{
|
||||
kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: t('sec.general'), id: 'general' },
|
||||
{ kind: 'item', label: t('sec.appearance'), id: 'appearance' },
|
||||
{ kind: 'item', label: t('sec.email'), id: 'email' },
|
||||
{ kind: 'item', label: t('sec.lookup'), id: 'lookup' },
|
||||
{ kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
|
||||
@@ -290,7 +293,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
// Map section id → i18n key (breadcrumb / placeholders).
|
||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
webpublish: 'sec.webpublish',
|
||||
@@ -689,6 +692,13 @@ const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '1
|
||||
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
|
||||
// filter is a subset of these. Must match motorBands in app.go, low → high.
|
||||
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
// Shown as placeholders only — the backend owns these values (motorBands in
|
||||
// app.go) and resolves what a band button actually commands. Duplicated here
|
||||
// purely so an empty box can say what leaving it empty will do.
|
||||
const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
|
||||
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
|
||||
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
|
||||
};
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||
|
||||
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
|
||||
@@ -1214,6 +1224,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
hamqth_user: '', hamqth_password: '',
|
||||
primary: '', failsafe: '',
|
||||
download_images: false,
|
||||
qrz_prefer_nickname: false,
|
||||
cache_ttl_days: 30,
|
||||
});
|
||||
// Per-provider Test state — keeps the success/error feedback adjacent
|
||||
@@ -1250,8 +1261,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
|
||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
|
||||
});
|
||||
const [ubTesting, setUbTesting] = useState(false);
|
||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
@@ -1431,13 +1442,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flags: string[]; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||
on_air?: boolean;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flags: ['N', 'R'], write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), delete_remote: false,
|
||||
@@ -1545,6 +1558,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// has side effects there — adding the RBN nodes, bringing the PSK Reporter
|
||||
// feed up or down — so the write has to go where those live.
|
||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||
const [chaseGrids, setChaseGrids] = useState(false);
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||
const [gridStat, setGridStat] = useState<any>(null);
|
||||
const [pskrStatus, setPskrStatus] = useState<any>(null);
|
||||
const saveBandOpen = async (next: any) => {
|
||||
setBandOpen(next);
|
||||
@@ -1553,11 +1570,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||
})();
|
||||
// Poll the feed while the panel is open: a live count is the only thing that
|
||||
// distinguishes "connected" from "connected and receiving nothing".
|
||||
const t = window.setInterval(async () => {
|
||||
try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
|
||||
try { setGridStat(await GetGridCacheStatus()); } catch { /* ignore */ }
|
||||
}, 3000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
@@ -2295,6 +2315,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* QRZ only. HamQTH's <nick> already fills the Name field with the
|
||||
on-air name, so the same switch there would toggle a behaviour it
|
||||
has no way to turn off. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer mt-3">
|
||||
<Checkbox
|
||||
checked={!!(lookup as any).qrz_prefer_nickname}
|
||||
onCheckedChange={(c) => setLookup((s) => ({ ...s, qrz_prefer_nickname: !!c } as any))}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t('lk.qrzNickname')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
{t('lk.qrzNicknameHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
@@ -3140,45 +3177,96 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||
Follow rig frequency (auto-tune the antenna)
|
||||
{t('hw.motorFollow')}
|
||||
</label>
|
||||
{ultrabeam.follow && (
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Label className="text-sm">Re-tune step</Label>
|
||||
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25 kHz</SelectItem>
|
||||
<SelectItem value="50">50 kHz</SelectItem>
|
||||
<SelectItem value="100">100 kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">re-tune only when the frequency moves this far</span>
|
||||
<div className="space-y-2 pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm">{t('station.trackModeTip')}</Label>
|
||||
<Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
|
||||
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="always">{t('station.trackAlways')}</SelectItem>
|
||||
<SelectItem value="step">{t('station.trackStep')}</SelectItem>
|
||||
<SelectItem value="band">{t('station.trackBand')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* The step is only a question in step mode — the other two modes
|
||||
have nothing to threshold. */}
|
||||
{(ultrabeam.track_mode || 'step') === 'step' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm">{t('hw.motorStep')}</Label>
|
||||
<Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
|
||||
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25 kHz</SelectItem>
|
||||
<SelectItem value="50">50 kHz</SelectItem>
|
||||
<SelectItem value="100">100 kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
|
||||
: (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
|
||||
: t('station.trackStepTipMode')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<Label className="text-sm">{t('hw.motorBands')}</Label>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* Band, and under it the frequency its button tunes to — the
|
||||
layout of the SteppIR controller's own Bands and Frequencies
|
||||
table, which is where operators expect to find this. The box
|
||||
only appears on a selected band: a tune frequency for a band the
|
||||
antenna is not allowed on is a setting with no effect. Left
|
||||
empty it shows the default as placeholder, so the field is
|
||||
self-documenting and clearing it is how you go back. */}
|
||||
<div className="flex items-start gap-1.5 flex-wrap">
|
||||
{MOTOR_BANDS.map((b) => {
|
||||
const on = ultrabeam.bands.includes(b);
|
||||
return (
|
||||
<button key={b} type="button"
|
||||
onClick={() => setUltrabeam((s) => ({
|
||||
...s,
|
||||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
||||
}))}
|
||||
className={`h-8 min-w-[3rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||||
on
|
||||
? 'border-primary bg-primary/15 text-primary'
|
||||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}>
|
||||
{b}
|
||||
</button>
|
||||
<div key={b} className="flex flex-col gap-1">
|
||||
<button type="button"
|
||||
onClick={() => setUltrabeam((s) => ({
|
||||
...s,
|
||||
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
|
||||
}))}
|
||||
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
|
||||
on
|
||||
? 'border-primary bg-primary/15 text-primary'
|
||||
: 'border-input bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}>
|
||||
{b}
|
||||
</button>
|
||||
{on && (
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
|
||||
placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
|
||||
title={t('hw.motorBandFreqHint')}
|
||||
onChange={(e) => {
|
||||
// Keep only digits, and store nothing for an empty box
|
||||
// so it round-trips to "use the default" rather than
|
||||
// to a zero the backend would have to interpret.
|
||||
const digits = e.target.value.replace(/[^0-9]/g, '');
|
||||
setUltrabeam((s) => {
|
||||
const next = { ...(s.band_freqs || {}) };
|
||||
if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
|
||||
return { ...s, band_freqs: next };
|
||||
});
|
||||
}}
|
||||
className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||
@@ -4169,6 +4257,52 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
things set up once. A preferences dialog you reopen every ten minutes
|
||||
is a filter in the wrong place. */}
|
||||
|
||||
{/* Grid chasing. Here rather than in the filter panel because it is set
|
||||
up once: it decides whether locators learnt from decodes are KEPT
|
||||
across restarts, not what the list shows right now. */}
|
||||
{/* Spot lifetime. Applies to the cluster list AND every band map: the
|
||||
spots are removed from the shared list rather than hidden, so the
|
||||
setting cannot mean one thing in one view and another elsewhere. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm">{t('clu.spotTtl')}</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
|
||||
{[0, 5, 10, 15, 30, 60].map((v) => (
|
||||
<button key={v} type="button"
|
||||
onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
|
||||
className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{v === 0 ? t('clu.spotTtlNever') : `${v}′`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Raw text in local state: binding the input straight to the
|
||||
clamped number makes an empty field impossible to type into. */}
|
||||
<Input className="h-8 w-20" value={spotTTLText}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||
setSpotTTLText(raw);
|
||||
const n = Math.min(720, parseInt(raw, 10) || 0);
|
||||
setSpotTTL(n);
|
||||
SetSpotTTLMinutes(n).catch(() => {});
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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={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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||
switching it on adds two of them — the operator should see that
|
||||
happen where it happens rather than find nodes they did not add. */}
|
||||
@@ -4887,6 +5021,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
|
||||
{/* ON AIR is a live status, not an upload: it needs the rig
|
||||
readable, not a QSO. Its own switch beside the upload one
|
||||
rather than folded into it. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox className="mt-0.5"
|
||||
checked={!!(hrdlog as any).on_air}
|
||||
onCheckedChange={(c) => setHrdlog({ on_air: !!c } as any)}
|
||||
/>
|
||||
<span>
|
||||
{t('es.hrdOnAir')}
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">{t('es.hrdOnAirHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||
<Select
|
||||
@@ -6084,6 +6232,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
general: GeneralPanel,
|
||||
appearance: () => <AppearancePanel />,
|
||||
email: EmailPanel,
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
|
||||
@@ -77,6 +77,8 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
@@ -116,7 +118,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
);
|
||||
}
|
||||
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] };
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
|
||||
|
||||
// Where each band button points the antenna.
|
||||
//
|
||||
@@ -241,7 +243,10 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{(ant.bands ?? []).map((b: string) => {
|
||||
const khz = ANT_BAND_KHZ[b];
|
||||
// Where this band tunes is resolved by the backend — the operator's
|
||||
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
|
||||
// the floor for a status poll that hasn't landed yet.
|
||||
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
|
||||
if (!khz) return null;
|
||||
// "On this band" from the antenna's own frequency, not the rig's:
|
||||
// the widget must show where the ANTENNA is, which is the whole
|
||||
@@ -277,23 +282,38 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
|
||||
{/* Tracking. Here rather than only in Settings because it is an operating
|
||||
decision — off to park the antenna, on to resume — not something set
|
||||
up once. The step only shows when tracking is on: a threshold for
|
||||
something switched off is a question the operator cannot act on. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => run(SetMotorFollow(!ant.follow, 0))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||
</button>
|
||||
up once. Mode and step only show when tracking is on: settings for
|
||||
something switched off are questions the operator cannot act on. And
|
||||
the step only shows in step mode, where it is the one thing it means. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
||||
className={cn('flex-1 rounded-md border py-1.5 text-xs font-semibold transition-colors',
|
||||
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
||||
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
||||
</button>
|
||||
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
||||
<select
|
||||
value={String(ant.step_khz || 50)}
|
||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||
title={t('station.trackStepTip')}
|
||||
>
|
||||
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{ant.follow && (
|
||||
<select
|
||||
value={String(ant.step_khz || 50)}
|
||||
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10)))}
|
||||
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
||||
title={t('station.trackStepTip')}
|
||||
value={ant.track_mode || 'step'}
|
||||
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
||||
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
||||
title={t('station.trackModeTip')}
|
||||
>
|
||||
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
||||
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
||||
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
||||
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+19
-19
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
// Row colouring for the log grid, by QSL status.
|
||||
//
|
||||
// Four categories, each scoped to the channels the operator cares about —
|
||||
// paper QSL, LoTW, eQSL, QRZ.com. The rules are ORDERED and the first match
|
||||
// wins, because a contact is usually several of them at once.
|
||||
|
||||
export type RowColorRule = {
|
||||
id: string;
|
||||
color: string;
|
||||
enabled: boolean;
|
||||
channels?: string[]; // empty = every channel
|
||||
};
|
||||
export type RowColorSettings = {
|
||||
enabled: boolean;
|
||||
style?: 'bar' | 'tint' | 'both';
|
||||
intensity?: number;
|
||||
bandmap_lotw?: boolean;
|
||||
rules: RowColorRule[];
|
||||
};
|
||||
|
||||
export const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
|
||||
|
||||
// The two QSO fields behind each channel. QRZ.com and Club Log call theirs an
|
||||
// "upload status" rather than a QSL flag, but they carry the same Y / R letters.
|
||||
const FIELDS: Record<string, { sent: string; rcvd: string }> = {
|
||||
qsl: { sent: 'qsl_sent', rcvd: 'qsl_rcvd' },
|
||||
lotw: { sent: 'lotw_sent', rcvd: 'lotw_rcvd' },
|
||||
eqsl: { sent: 'eqsl_sent', rcvd: 'eqsl_rcvd' },
|
||||
qrz: { sent: 'qrzcom_qso_upload_status', rcvd: 'qrzcom_qso_download_status' },
|
||||
};
|
||||
|
||||
// ADIF QSL fields are single letters. Y is the only one that means "yes";
|
||||
// R (requested) and Q (queued) mean it has not gone out yet — a different state,
|
||||
// and the one an operator looks for when deciding what to send.
|
||||
const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y';
|
||||
const owed = (v: any) => {
|
||||
const s = String(v ?? '').trim().toUpperCase();
|
||||
return s === 'R' || s === 'Q';
|
||||
};
|
||||
|
||||
const chansOf = (r: RowColorRule): readonly string[] =>
|
||||
r.channels && r.channels.length ? r.channels : CHANNELS;
|
||||
|
||||
function ruleMatches(q: any, r: RowColorRule): boolean {
|
||||
const cs = chansOf(r);
|
||||
switch (r.id) {
|
||||
case 'confirmed':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.rcvd]));
|
||||
case 'sent':
|
||||
return cs.some((c) => yes(q[FIELDS[c]?.sent]));
|
||||
case 'to_send':
|
||||
return cs.some((c) => owed(q[FIELDS[c]?.sent]));
|
||||
case 'worked':
|
||||
// The catch-all: nothing sent, nothing asked for, nothing back.
|
||||
return !CHANNELS.some((c) => yes(q[FIELDS[c].rcvd]) || yes(q[FIELDS[c].sent]) || owed(q[FIELDS[c].sent]));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the rules IN ORDER — the order is the priority, and the settings panel
|
||||
// shows it numbered so it can be read rather than guessed.
|
||||
export function matchRowRule(q: any, cfg: RowColorSettings | null): RowColorRule | null {
|
||||
if (!q || !cfg?.rules) return null;
|
||||
for (const r of cfg.rules) {
|
||||
if (r.enabled && ruleMatches(q, r)) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The colour is never a fill by default.
|
||||
//
|
||||
// A log where nearly every contact has SOME QSL state ends up with every row
|
||||
// painted, and colour that is always present stops being information. The
|
||||
// default is a stripe down the left edge; a tint is offered at a chosen strength.
|
||||
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
|
||||
if (!cfg?.enabled) return undefined;
|
||||
const rule = matchRowRule(q, cfg);
|
||||
if (!rule?.color) return undefined;
|
||||
|
||||
const style = cfg.style ?? 'bar';
|
||||
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
|
||||
const out: Record<string, string> = {};
|
||||
if (style === 'tint' || style === 'both') {
|
||||
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
|
||||
}
|
||||
if (style === 'bar' || style === 'both') {
|
||||
// inset shadow rather than a border: a border would shift the cells three
|
||||
// pixels on coloured rows only, and the columns would stop lining up.
|
||||
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -13,7 +13,17 @@
|
||||
|
||||
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
||||
|
||||
// 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
|
||||
// switches back is this one flag and the block they came from in App.tsx.
|
||||
//
|
||||
// The saved preferences are left untouched in localStorage rather than cleared:
|
||||
// an operator who had either turned on gets them back exactly as they were the
|
||||
// day the options return, instead of silently starting from off.
|
||||
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||
|
||||
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
||||
try {
|
||||
return {
|
||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||
@@ -64,3 +74,16 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// spotIsWorked answers "does this spot bring me nothing?", which is what the
|
||||
// "hide worked" filter is really asking.
|
||||
//
|
||||
// The extra markers are ORTHOGONAL to the entity status — a spot can be a worked
|
||||
// entity AND a new park, prefix, county or grid — so they win. Without that, a
|
||||
// new prefix in an entity worked years ago vanished the moment the filter went
|
||||
// on, which is the opposite of what an operator hunting prefixes wants.
|
||||
export function spotIsWorked(e: Entry): boolean {
|
||||
if (!e) return false;
|
||||
if (e.new_pota || e.new_county || e.new_pfx || e.new_grid) return false;
|
||||
return !!e.worked_call || e.status === 'worked';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
export const APP_VERSION = '0.24.6';
|
||||
export const APP_VERSION = '0.25.0';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+17
-1
@@ -404,6 +404,8 @@ export function GetCatalogCodes():Promise<Array<string>>;
|
||||
|
||||
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetChaseNewGrids():Promise<boolean>;
|
||||
|
||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
@@ -440,6 +442,8 @@ export function GetFlexBandPower():Promise<Record<string, main.FlexBandPower>>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
@@ -488,6 +492,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
|
||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||
|
||||
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||
|
||||
export function GetSPEStatus():Promise<spe.Status>;
|
||||
|
||||
export function GetScpStatus():Promise<main.ScpStatus>;
|
||||
@@ -500,6 +506,8 @@ export function GetSlotStats():Promise<qso.SlotStats>;
|
||||
|
||||
export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetSpotTTLMinutes():Promise<number>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
@@ -924,6 +932,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
|
||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||
|
||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
@@ -970,19 +980,23 @@ export function SetCIVTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetChaseNewGrids(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactHeight(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function SetMotorFollow(arg1:boolean,arg2:number):Promise<void>;
|
||||
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||
|
||||
@@ -990,6 +1004,8 @@ export function SetPassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetSpotTTLMinutes(arg1:number):Promise<void>;
|
||||
|
||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
@@ -750,6 +750,10 @@ export function GetChangelog() {
|
||||
return window['go']['main']['App']['GetChangelog']();
|
||||
}
|
||||
|
||||
export function GetChaseNewGrids() {
|
||||
return window['go']['main']['App']['GetChaseNewGrids']();
|
||||
}
|
||||
|
||||
export function GetChatHistory(arg1) {
|
||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||
}
|
||||
@@ -822,6 +826,10 @@ export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
|
||||
export function GetGridCacheStatus() {
|
||||
return window['go']['main']['App']['GetGridCacheStatus']();
|
||||
}
|
||||
|
||||
export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
@@ -918,6 +926,10 @@ export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetRowColors() {
|
||||
return window['go']['main']['App']['GetRowColors']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
return window['go']['main']['App']['GetSPEStatus']();
|
||||
}
|
||||
@@ -942,6 +954,10 @@ export function GetSolarData() {
|
||||
return window['go']['main']['App']['GetSolarData']();
|
||||
}
|
||||
|
||||
export function GetSpotTTLMinutes() {
|
||||
return window['go']['main']['App']['GetSpotTTLMinutes']();
|
||||
}
|
||||
|
||||
export function GetStartupStatus() {
|
||||
return window['go']['main']['App']['GetStartupStatus']();
|
||||
}
|
||||
@@ -1790,6 +1806,10 @@ export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRowColors(arg1) {
|
||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||
}
|
||||
|
||||
export function SaveSelfSpotSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveSelfSpotSettings'](arg1);
|
||||
}
|
||||
@@ -1882,6 +1902,10 @@ export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetChaseNewGrids(arg1) {
|
||||
return window['go']['main']['App']['SetChaseNewGrids'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
@@ -1894,6 +1918,10 @@ export function SetClusterAutoConnect(arg1) {
|
||||
return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
|
||||
}
|
||||
|
||||
export function SetCompactHeight(arg1) {
|
||||
return window['go']['main']['App']['SetCompactHeight'](arg1);
|
||||
}
|
||||
|
||||
export function SetCompactMode(arg1) {
|
||||
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||
}
|
||||
@@ -1906,8 +1934,8 @@ export function SetKenwoodKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function SetMotorFollow(arg1, arg2) {
|
||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2);
|
||||
export function SetMotorFollow(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function SetOpsLogQSLReceived(arg1, arg2) {
|
||||
@@ -1922,6 +1950,10 @@ export function SetScpEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetScpEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetSpotTTLMinutes(arg1) {
|
||||
return window['go']['main']['App']['SetSpotTTLMinutes'](arg1);
|
||||
}
|
||||
|
||||
export function SetTelemetryEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -1331,6 +1331,7 @@ export namespace extsvc {
|
||||
upload_flags: string[];
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
on_air: boolean;
|
||||
upload_mode: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1355,6 +1356,7 @@ export namespace extsvc {
|
||||
this.upload_flags = source["upload_flags"];
|
||||
this.write_log = source["write_log"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.on_air = source["on_air"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
}
|
||||
}
|
||||
@@ -1441,6 +1443,8 @@ export namespace lookup {
|
||||
cont?: string;
|
||||
email?: string;
|
||||
qsl_via?: string;
|
||||
web?: string;
|
||||
zip?: string;
|
||||
image_url?: string;
|
||||
source: string;
|
||||
// Go type: time
|
||||
@@ -1468,6 +1472,8 @@ export namespace lookup {
|
||||
this.cont = source["cont"];
|
||||
this.email = source["email"];
|
||||
this.qsl_via = source["qsl_via"];
|
||||
this.web = source["web"];
|
||||
this.zip = source["zip"];
|
||||
this.image_url = source["image_url"];
|
||||
this.source = source["source"];
|
||||
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||
@@ -2419,6 +2425,22 @@ export namespace main {
|
||||
this.body = source["body"];
|
||||
}
|
||||
}
|
||||
export class GridCacheStatus {
|
||||
enabled: boolean;
|
||||
known: number;
|
||||
pending: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new GridCacheStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.known = source["known"];
|
||||
this.pending = source["pending"];
|
||||
}
|
||||
}
|
||||
export class ModePreset {
|
||||
name: string;
|
||||
default_rst_sent?: string;
|
||||
@@ -2521,6 +2543,7 @@ export namespace main {
|
||||
primary: string;
|
||||
failsafe: string;
|
||||
download_images: boolean;
|
||||
qrz_prefer_nickname: boolean;
|
||||
cache_ttl_days: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -2536,6 +2559,7 @@ export namespace main {
|
||||
this.primary = source["primary"];
|
||||
this.failsafe = source["failsafe"];
|
||||
this.download_images = source["download_images"];
|
||||
this.qrz_prefer_nickname = source["qrz_prefer_nickname"];
|
||||
this.cache_ttl_days = source["cache_ttl_days"];
|
||||
}
|
||||
}
|
||||
@@ -2972,6 +2996,64 @@ export namespace main {
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class RowColorRule {
|
||||
id: string;
|
||||
color: string;
|
||||
enabled: boolean;
|
||||
channels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RowColorRule(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.color = source["color"];
|
||||
this.enabled = source["enabled"];
|
||||
this.channels = source["channels"];
|
||||
}
|
||||
}
|
||||
export class RowColorSettings {
|
||||
enabled: boolean;
|
||||
style: string;
|
||||
intensity: number;
|
||||
bandmap_lotw: boolean;
|
||||
configured: boolean;
|
||||
rules: RowColorRule[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RowColorSettings(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.style = source["style"];
|
||||
this.intensity = source["intensity"];
|
||||
this.bandmap_lotw = source["bandmap_lotw"];
|
||||
this.configured = source["configured"];
|
||||
this.rules = this.convertValues(source["rules"], RowColorRule);
|
||||
}
|
||||
|
||||
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 class ScpStatus {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
@@ -3283,8 +3365,10 @@ export namespace main {
|
||||
baud: number;
|
||||
follow: boolean;
|
||||
step_khz: number;
|
||||
track_mode: string;
|
||||
tx_inhibit: boolean;
|
||||
bands: string[];
|
||||
band_freqs: Record<string, number>;
|
||||
freq_min_mhz: number;
|
||||
freq_max_mhz: number;
|
||||
|
||||
@@ -3303,8 +3387,10 @@ export namespace main {
|
||||
this.baud = source["baud"];
|
||||
this.follow = source["follow"];
|
||||
this.step_khz = source["step_khz"];
|
||||
this.track_mode = source["track_mode"];
|
||||
this.tx_inhibit = source["tx_inhibit"];
|
||||
this.bands = source["bands"];
|
||||
this.band_freqs = source["band_freqs"];
|
||||
this.freq_min_mhz = source["freq_min_mhz"];
|
||||
this.freq_max_mhz = source["freq_max_mhz"];
|
||||
}
|
||||
@@ -3320,7 +3406,9 @@ export namespace main {
|
||||
elements: number[];
|
||||
follow: boolean;
|
||||
step_khz: number;
|
||||
track_mode: string;
|
||||
bands: string[];
|
||||
band_freqs: Record<string, number>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UltrabeamStatusInfo(source);
|
||||
@@ -3338,7 +3426,9 @@ export namespace main {
|
||||
this.elements = source["elements"];
|
||||
this.follow = source["follow"];
|
||||
this.step_khz = source["step_khz"];
|
||||
this.track_mode = source["track_mode"];
|
||||
this.bands = source["bands"];
|
||||
this.band_freqs = source["band_freqs"];
|
||||
}
|
||||
}
|
||||
export class UpdateInfo {
|
||||
@@ -4178,6 +4268,7 @@ export namespace qso {
|
||||
}
|
||||
export class QSO {
|
||||
id: number;
|
||||
number?: number;
|
||||
callsign: string;
|
||||
// Go type: time
|
||||
qso_date: any;
|
||||
@@ -4318,6 +4409,7 @@ export namespace qso {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.number = source["number"];
|
||||
this.callsign = source["callsign"];
|
||||
this.qso_date = this.convertValues(source["qso_date"], null);
|
||||
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
// HRDLog "ON AIR" — publishing the live frequency, mode and rig on hrdlog.net,
|
||||
// so the site shows "F4BPO is on air 14,074,000 FT8 IC-7300" while the station
|
||||
// is operating.
|
||||
//
|
||||
// The endpoint and its field names come from HRDLog's own library
|
||||
// (github.com/iw1qlh/HRDLOG-net-library), not from reading someone's traffic.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/extsvc"
|
||||
)
|
||||
|
||||
// onAirEvery is how often the status is refreshed. HRDLog drops a station from
|
||||
// the on-air list when it stops hearing from it, so this is a heartbeat, not a
|
||||
// change notification.
|
||||
const onAirEvery = 2 * time.Minute
|
||||
|
||||
// hrdlogOnAirLoop publishes while the option is on and the rig is readable.
|
||||
func (a *App) hrdlogOnAirLoop() {
|
||||
defer func() { _ = recover() }() // never crash the app from a status broadcast
|
||||
t := time.NewTicker(onAirEvery)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
a.publishHRDLogOnAir()
|
||||
}
|
||||
}
|
||||
|
||||
// publishHRDLogOnAir sends one update. Silent when switched off, unconfigured,
|
||||
// or when the rig has nothing to report — an announcement with no frequency
|
||||
// would say the operator is on air on 0 Hz.
|
||||
func (a *App) publishHRDLogOnAir() {
|
||||
cfg := a.loadExternalServices().HRDLog
|
||||
if !cfg.OnAir || strings.TrimSpace(cfg.Callsign) == "" || strings.TrimSpace(cfg.Code) == "" {
|
||||
return
|
||||
}
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
st := a.cat.State()
|
||||
if !st.Connected || st.FreqHz <= 0 {
|
||||
return
|
||||
}
|
||||
// The rig NAME as the operator knows it — that string is what the site
|
||||
// displays. The active profile's MyRig first, since it is what every QSO is
|
||||
// stamped with; the CAT backend's own model only when the profile has none.
|
||||
radio := ""
|
||||
if a.profiles != nil {
|
||||
if pr, err := a.profiles.Active(a.ctx); err == nil {
|
||||
radio = strings.TrimSpace(pr.MyRig)
|
||||
}
|
||||
}
|
||||
if radio == "" {
|
||||
radio = strings.TrimSpace(st.Rig)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
msg, err := extsvc.SendHRDLogOnAir(ctx, nil, cfg.Callsign, cfg.Code, st.FreqHz, st.Mode, radio)
|
||||
if err != nil {
|
||||
// Logged, never surfaced: this is a broadcast the operator did not ask
|
||||
// for at this instant, and a toast every two minutes on a flaky link
|
||||
// would be worse than the missing status.
|
||||
applog.Printf("hrdlog on-air: %v (%s)", err, msg)
|
||||
return
|
||||
}
|
||||
applog.Printf("hrdlog on-air: %s %.3f MHz %s (%s) — %s",
|
||||
cfg.Callsign, float64(st.FreqHz)/1e6, st.Mode, radio, msg)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ func TestCharCountLengthRepair(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "cyrillic",
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
|
||||
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
|
||||
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package adif
|
||||
|
||||
import "testing"
|
||||
|
||||
// ADIF defines CNTY as "STATE,COUNTY" — "GA,BARROW". OpsLog exported the bare
|
||||
// county, which a receiving logger cannot resolve: county names repeat across
|
||||
// states, and there is a Washington County in thirty of them.
|
||||
func TestADIFCountyFormat(t *testing.T) {
|
||||
for _, tc := range []struct{ state, county, want string }{
|
||||
{"GA", "BARROW", "GA,BARROW"},
|
||||
{"ga", "Barrow", "GA,Barrow"}, // the state is a code, upper-cased; the name is not touched
|
||||
{"", "BARROW", "BARROW"}, // no state known: a bare name beats nothing
|
||||
{"GA", "", ""}, // no county: nothing to write
|
||||
{"GA", "GA,BARROW", "GA,BARROW"}, // already formatted — never double the prefix
|
||||
{" GA ", " BARROW ", "GA,BARROW"},
|
||||
} {
|
||||
if got := adifCounty(tc.state, tc.county); got != tc.want {
|
||||
t.Errorf("adifCounty(%q, %q) = %q, want %q", tc.state, tc.county, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the mirror: a file from a logger that follows the standard must fill both
|
||||
// columns, while a file carrying the bare county still imports unchanged.
|
||||
func TestSplitADIFCounty(t *testing.T) {
|
||||
for _, tc := range []struct{ in, county, state string }{
|
||||
{"GA,BARROW", "BARROW", "GA"},
|
||||
{"ga, Barrow", "Barrow", "GA"},
|
||||
{"BARROW", "BARROW", ""}, // the old OpsLog form, and many other loggers
|
||||
{"", "", ""},
|
||||
{" ", "", ""},
|
||||
} {
|
||||
c, s := splitADIFCounty(tc.in)
|
||||
if c != tc.county || s != tc.state {
|
||||
t.Errorf("splitADIFCounty(%q) = (%q, %q), want (%q, %q)", tc.in, c, s, tc.county, tc.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Round trip: what OpsLog writes, OpsLog reads back to the same two values.
|
||||
func TestCountyRoundTrip(t *testing.T) {
|
||||
c, s := splitADIFCounty(adifCounty("GA", "BARROW"))
|
||||
if c != "BARROW" || s != "GA" {
|
||||
t.Errorf("round trip gave (%q, %q), want (BARROW, GA)", c, s)
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -15,9 +15,9 @@ import (
|
||||
|
||||
// ExportResult summarises an ADIF export for the UI.
|
||||
type ExportResult struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
SizeKB int64 `json:"size_kb"`
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
SizeKB int64 `json:"size_kb"`
|
||||
}
|
||||
|
||||
// Exporter streams every QSO in a repo to an ADIF (.adi) file.
|
||||
@@ -221,7 +221,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("VUCC_GRIDS", q.VUCCGrids)
|
||||
w("COUNTRY", q.Country)
|
||||
w("STATE", q.State)
|
||||
w("CNTY", q.County)
|
||||
w("CNTY", adifCounty(q.State, q.County))
|
||||
wi("DXCC", q.DXCC)
|
||||
w("CONT", q.Continent)
|
||||
wi("CQZ", q.CQZ)
|
||||
@@ -285,7 +285,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
|
||||
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||
w("MY_COUNTRY", q.MyCountry)
|
||||
w("MY_STATE", q.MyState)
|
||||
w("MY_CNTY", q.MyCounty)
|
||||
w("MY_CNTY", adifCounty(q.MyState, q.MyCounty))
|
||||
w("MY_IOTA", q.MyIOTA)
|
||||
w("MY_SOTA_REF", q.MySOTARef)
|
||||
w("MY_POTA_REF", q.MyPOTARef)
|
||||
@@ -391,10 +391,10 @@ var parentMode = map[string]string{
|
||||
"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
|
||||
"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
|
||||
"OLIVIA": "MFSK",
|
||||
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
|
||||
"FREEDV": "DIGITALVOICE",
|
||||
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||
"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
|
||||
"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
|
||||
"HELL80": "HELL", "FMHELL": "HELL",
|
||||
@@ -413,3 +413,25 @@ func modeForExport(mode, submode string) (string, string) {
|
||||
}
|
||||
return mode, ""
|
||||
}
|
||||
|
||||
// adifCounty renders CNTY the way ADIF specifies: "STATE,COUNTY", e.g.
|
||||
// "GA,BARROW".
|
||||
//
|
||||
// OpsLog stores the two apart, and exported only the county — which a receiving
|
||||
// logger cannot resolve, since county names repeat across states (there is a
|
||||
// Washington County in thirty of them). The award engine here was never
|
||||
// affected because it reads the columns, not the ADIF; the damage was to
|
||||
// everyone we send a file to.
|
||||
//
|
||||
// The county alone is still written when no state is known: a bare name is
|
||||
// worth more than nothing, and inventing a prefix would be worse than either.
|
||||
// A county that ALREADY carries a comma is passed through — it has been
|
||||
// formatted once, and doubling the prefix would corrupt it.
|
||||
func adifCounty(state, county string) string {
|
||||
c := strings.TrimSpace(county)
|
||||
s := strings.TrimSpace(state)
|
||||
if c == "" || s == "" || strings.Contains(c, ",") {
|
||||
return c
|
||||
}
|
||||
return strings.ToUpper(s) + "," + c
|
||||
}
|
||||
|
||||
+13
-13
@@ -16,23 +16,23 @@ import "strings"
|
||||
type FieldKind string
|
||||
|
||||
const (
|
||||
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||
KindEnum FieldKind = "enum" // Enumeration
|
||||
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||
KindText FieldKind = "text" // String / IntlString / MultilineString
|
||||
KindNumber FieldKind = "number" // Number / PositiveInteger
|
||||
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
|
||||
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
|
||||
KindBool FieldKind = "boolean" // Boolean (Y/N)
|
||||
KindEnum FieldKind = "enum" // Enumeration
|
||||
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
|
||||
)
|
||||
|
||||
// FieldDef describes one ADIF QSO field.
|
||||
type FieldDef struct {
|
||||
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||
Kind FieldKind `json:"kind"` // editor widget hint
|
||||
Category string `json:"category"` // grouping for the UI
|
||||
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||
Name string `json:"name"` // canonical uppercase ADIF tag
|
||||
Kind FieldKind `json:"kind"` // editor widget hint
|
||||
Category string `json:"category"` // grouping for the UI
|
||||
Promoted bool `json:"promoted"` // has a dedicated QSO column
|
||||
Deprecated bool `json:"deprecated"` // import-only per the spec
|
||||
Intl bool `json:"intl"` // *_INTL UTF-8 variant
|
||||
}
|
||||
|
||||
// adifVersion is the ADIF spec version OpsLog targets for import/export.
|
||||
|
||||
+29
-2
@@ -428,7 +428,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
|
||||
q.Country = rec["country"]
|
||||
q.State = strings.ToUpper(rec["state"])
|
||||
q.County = rec["cnty"]
|
||||
q.County, _ = splitADIFCounty(rec["cnty"])
|
||||
if st := rec["state"]; strings.TrimSpace(st) == "" {
|
||||
if _, fromCnty := splitADIFCounty(rec["cnty"]); fromCnty != "" {
|
||||
q.State = fromCnty
|
||||
}
|
||||
}
|
||||
if v, ok := parseInt(rec["dxcc"]); ok {
|
||||
q.DXCC = &v
|
||||
}
|
||||
@@ -515,7 +520,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
|
||||
q.MyCountry = rec["my_country"]
|
||||
q.MyState = strings.ToUpper(rec["my_state"])
|
||||
q.MyCounty = rec["my_cnty"]
|
||||
q.MyCounty, _ = splitADIFCounty(rec["my_cnty"])
|
||||
if st := rec["my_state"]; strings.TrimSpace(st) == "" {
|
||||
if _, fromCnty := splitADIFCounty(rec["my_cnty"]); fromCnty != "" {
|
||||
q.MyState = fromCnty
|
||||
}
|
||||
}
|
||||
q.MyIOTA = strings.ToUpper(rec["my_iota"])
|
||||
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
|
||||
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
|
||||
@@ -698,3 +708,20 @@ var promotableSubmodes = map[string]bool{
|
||||
func submodeSubsumesParent(submode string) bool {
|
||||
return promotableSubmodes[submode]
|
||||
}
|
||||
|
||||
// splitADIFCounty reads CNTY, which ADIF defines as "STATE,COUNTY".
|
||||
//
|
||||
// Returns the county on its own plus the state the field carried, so an import
|
||||
// from a logger that follows the standard fills BOTH columns here. Files that
|
||||
// write the bare county — as OpsLog itself used to — still import unchanged.
|
||||
//
|
||||
// The state from the field never overrides an explicit STATE tag: STATE is the
|
||||
// dedicated field and the more specific statement. It only fills a blank.
|
||||
func splitADIFCounty(cnty string) (county, state string) {
|
||||
cnty = strings.TrimSpace(cnty)
|
||||
i := strings.Index(cnty, ",")
|
||||
if i < 0 {
|
||||
return cnty, ""
|
||||
}
|
||||
return strings.TrimSpace(cnty[i+1:]), strings.ToUpper(strings.TrimSpace(cnty[:i]))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
|
||||
in := qso.QSO{
|
||||
Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
|
||||
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
|
||||
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
|
||||
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
|
||||
Distance: &dist, RXPower: &rxp, AIndex: &a,
|
||||
SKCC: "12345S", FISTS: "999", TenTen: "55555",
|
||||
|
||||
+50
-5
@@ -449,6 +449,34 @@ type RefMeta struct {
|
||||
Pattern string
|
||||
re *regexp.Regexp
|
||||
Valid bool
|
||||
// Per-reference validity window, ISO "2006-01-02". A reference is not
|
||||
// forever: a park is delisted, a county is merged, a castle loses its
|
||||
// reference number. A QSO made while it existed still counts — it was a valid
|
||||
// contact on the day — and one made after it stopped existing does not.
|
||||
//
|
||||
// Empty means "no window of its own", and the award's own ValidFrom/ValidTo
|
||||
// then govern, as they already do for every QSO in the award (see inScope).
|
||||
// That fallback is deliberately NOT duplicated here: two places enforcing the
|
||||
// same dates is two places for them to disagree.
|
||||
ValidFrom string
|
||||
ValidTo string
|
||||
}
|
||||
|
||||
// activeOn reports whether the reference existed on the day of the QSO.
|
||||
//
|
||||
// Compared as ISO date strings rather than parsed times on purpose: the stored
|
||||
// values are "2025-08-01"-shaped and lexical order on that shape IS
|
||||
// chronological order, so this cannot fail on a malformed date the way a parse
|
||||
// can — a reference with a typo in its window keeps counting instead of silently
|
||||
// vanishing from an operator's totals.
|
||||
func (m RefMeta) activeOn(day string) bool {
|
||||
if m.ValidFrom != "" && day < m.ValidFrom {
|
||||
return false
|
||||
}
|
||||
if m.ValidTo != "" && day > m.ValidTo {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||
@@ -956,11 +984,13 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
|
||||
// describes is worse than no trace, because it is believed.
|
||||
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
|
||||
predefined := hasList && !d.Dynamic
|
||||
// The day of the contact, for per-reference validity windows.
|
||||
day := q.QSODate.Format("2006-01-02")
|
||||
|
||||
// run executes one rule and, when tracing, records it.
|
||||
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||
kept := keepRefs(predefined, rl, raw)
|
||||
kept := keepRefs(predefined, rl, raw, day)
|
||||
if ex != nil {
|
||||
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||
@@ -974,7 +1004,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
if _, ok := keptSet[n]; ok {
|
||||
continue
|
||||
}
|
||||
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||
s.Rejected = append(s.Rejected, rejection(predefined, rl, n, day))
|
||||
}
|
||||
ex.Steps = append(ex.Steps, s)
|
||||
}
|
||||
@@ -1026,7 +1056,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||
// predefined award the ref is still validated against the list below.
|
||||
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||
manual := keepRefs(predefined, rl, manualRefs(q, d.Code), day)
|
||||
if ex != nil {
|
||||
ex.Manual = manual
|
||||
}
|
||||
@@ -1065,7 +1095,7 @@ func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList
|
||||
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||
// that nothing was printing.
|
||||
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||
func rejection(predefined bool, rl refList, code, day string) Rejected {
|
||||
switch {
|
||||
case code == "":
|
||||
return Rejected{Candidate: code, Reason: "empty"}
|
||||
@@ -1076,6 +1106,17 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
||||
if !ok {
|
||||
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||
}
|
||||
// Spell the dates out. "Did not count" on a contact the operator remembers
|
||||
// making is exactly the moment they need to be told it is the REFERENCE that
|
||||
// has a window, not their log that is wrong.
|
||||
if !m.activeOn(day) {
|
||||
switch {
|
||||
case m.ValidTo != "" && day > m.ValidTo:
|
||||
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference ceased to exist on %s, after this QSO of %s", m.ValidTo, day)}
|
||||
default:
|
||||
return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference did not exist until %s, after this QSO of %s", m.ValidFrom, day)}
|
||||
}
|
||||
}
|
||||
if !m.Valid {
|
||||
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||
}
|
||||
@@ -1088,7 +1129,7 @@ func rejection(predefined bool, rl refList, code string) Rejected {
|
||||
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||
// 291). Per-reference DXCC stays metadata for the picker.
|
||||
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||
func keepRefs(predefined bool, rl refList, found []string, day string) []string {
|
||||
if !predefined {
|
||||
out := make([]string, 0, len(found))
|
||||
for _, c := range found {
|
||||
@@ -1106,6 +1147,10 @@ func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||
if !ok || !m.Valid {
|
||||
continue
|
||||
}
|
||||
// The reference has to have existed on the day of the contact.
|
||||
if !m.activeOn(day) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[c]; dup {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
func day(s string) time.Time {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// A reference is not forever. A park is delisted, a district is merged, a castle
|
||||
// loses its number. A contact made while it existed still counts — it was a
|
||||
// valid contact on the day — and one made after it stopped existing does not.
|
||||
func TestRefValidityWindow(t *testing.T) {
|
||||
m := RefMeta{Code: "KL-01", Valid: true, ValidTo: "2025-08-31"}
|
||||
for _, tc := range []struct {
|
||||
day string
|
||||
want bool
|
||||
}{
|
||||
{"2019-01-01", true},
|
||||
{"2025-08-31", true}, // the last day it existed still counts
|
||||
{"2025-09-01", false},
|
||||
{"2026-08-12", false},
|
||||
} {
|
||||
if got := m.activeOn(tc.day); got != tc.want {
|
||||
t.Errorf("KL-01 on %s: active=%v, want %v", tc.day, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A reference that only came into being partway through.
|
||||
n := RefMeta{Code: "KL-99", Valid: true, ValidFrom: "2025-01-15"}
|
||||
if n.activeOn("2025-01-14") {
|
||||
t.Error("counted a QSO from before the reference existed")
|
||||
}
|
||||
if !n.activeOn("2025-01-15") {
|
||||
t.Error("the first day it existed must count")
|
||||
}
|
||||
|
||||
// No window of its own: the award's own dates govern, as they already do for
|
||||
// every QSO in the award. Nothing here may narrow that.
|
||||
if !(RefMeta{Code: "X", Valid: true}).activeOn("1970-01-01") {
|
||||
t.Error("a reference with no window must count on any date")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point: the same QSO counts before the cutoff and does not after.
|
||||
func TestExpiredRefStopsCountingForLaterQSOs(t *testing.T) {
|
||||
d := &Def{
|
||||
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||
Confirm: []string{"lotw"},
|
||||
}
|
||||
metas := []RefMeta{
|
||||
{Code: "KL-01", Name: "Petrozavodsk", Valid: true, ValidTo: "2025-08-31"},
|
||||
{Code: "KL-04", Name: "Kostomuksha", Valid: true},
|
||||
}
|
||||
|
||||
q := func(ref, on string) *qso.QSO {
|
||||
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: ref, QSODate: day(on)}
|
||||
}
|
||||
|
||||
if got := MatchQSO(*d, metas, q("KL-01", "2025-06-01")); len(got) != 1 || got[0] != "KL-01" {
|
||||
t.Errorf("a QSO made while KL-01 existed must count: got %v", got)
|
||||
}
|
||||
if got := MatchQSO(*d, metas, q("KL-01", "2025-09-15")); len(got) != 0 {
|
||||
t.Errorf("a QSO made after KL-01 ceased to exist must not count: got %v", got)
|
||||
}
|
||||
if got := MatchQSO(*d, metas, q("KL-04", "2025-09-15")); len(got) != 1 || got[0] != "KL-04" {
|
||||
t.Errorf("a reference with no window is unaffected: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim that an empty per-reference window "inherits the award's" has to be
|
||||
// a fact about the code, not a comment. Compute and MatchQSO both gate on
|
||||
// inScope, which enforces the award's own dates — so a reference with no window
|
||||
// of its own is already bounded by them, and duplicating the check per reference
|
||||
// would only create a second place for the same dates to disagree.
|
||||
func TestEmptyRefWindowInheritsTheAward(t *testing.T) {
|
||||
d := Def{
|
||||
Code: "RDA", Name: "Russian District Award", Valid: true,
|
||||
Type: TypeQSOFields, Field: "note", MatchBy: "code",
|
||||
Confirm: []string{"lotw"},
|
||||
ValidFrom: "1991-06-12", // the award itself starts here
|
||||
}
|
||||
metas := []RefMeta{{Code: "KL-04", Name: "Kostomuksha", Valid: true}} // no window of its own
|
||||
|
||||
q := func(on string) *qso.QSO {
|
||||
return &qso.QSO{Callsign: "RA1ABC", Band: "20m", Notes: "KL-04", QSODate: day(on)}
|
||||
}
|
||||
if got := MatchQSO(d, metas, q("1991-06-11")); len(got) != 0 {
|
||||
t.Errorf("a QSO before the AWARD's start counted for a reference with no window of its own: %v", got)
|
||||
}
|
||||
if got := MatchQSO(d, metas, q("1991-06-12")); len(got) != 1 {
|
||||
t.Errorf("a QSO on the award's first day must count: %v", got)
|
||||
}
|
||||
|
||||
// And a reference window NARROWER than the award's still applies on top.
|
||||
metas[0].ValidTo = "2025-08-31"
|
||||
if got := MatchQSO(d, metas, q("2025-09-01")); len(got) != 0 {
|
||||
t.Errorf("the reference's own end date was ignored: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,28 @@ var watched = map[string]bool{"10m": true, "6m": true, "4m": true, "2m": true}
|
||||
// Watched reports whether a band is one the detector looks at.
|
||||
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
||||
|
||||
// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
|
||||
// Zero means no limit.
|
||||
//
|
||||
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
|
||||
// on 6 m, and that was right — but "no limit anywhere" then let something else
|
||||
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
|
||||
// that were unmistakably working EME: the moon is not an opening, and pointing
|
||||
// an antenna at that bearing would find nothing.
|
||||
//
|
||||
// So the limit is per band, and it is physics rather than a threshold. Two
|
||||
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
|
||||
// clouds and no further; beyond that the path went via the moon or a satellite,
|
||||
// neither of which says anything about the band. Six and ten metres have no
|
||||
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
|
||||
var maxTerrestrialKm = map[string]int{
|
||||
"2m": 3500,
|
||||
"4m": 4000,
|
||||
}
|
||||
|
||||
// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
|
||||
func MaxKmFor(band string) int { return maxTerrestrialKm[strings.ToLower(strings.TrimSpace(band))] }
|
||||
|
||||
// Opening is a detected opening, ready to be announced.
|
||||
type Opening struct {
|
||||
Band string `json:"band"`
|
||||
@@ -130,6 +152,12 @@ func (d *Detector) Add(s Spot, lat float64) *Opening {
|
||||
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
||||
return nil
|
||||
}
|
||||
// Past what the atmosphere can carry on this band, the path went via the moon
|
||||
// or a satellite. Those are real contacts and real reports; they are simply
|
||||
// not evidence about the band.
|
||||
if m := MaxKmFor(band); m > 0 && s.DistKm > m {
|
||||
return nil
|
||||
}
|
||||
d.recent = append(d.recent, s)
|
||||
d.prune(s.At)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package bandopen
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A 2 m "opening" was announced at 9650 km towards Japan on stations that were
|
||||
// plainly working EME. The moon is not an opening: an operator pointing an
|
||||
// antenna at that bearing finds nothing.
|
||||
func TestEMEIsNotAnOpeningOnTwoMetres(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
base := time.Date(2026, 8, 12, 6, 0, 0, 0, time.UTC)
|
||||
for i, call := range []string{"7M4RRM", "JA7RPC", "JF1AWC", "JK1TPA", "JH1JCQ"} {
|
||||
if op := d.Add(Spot{
|
||||
Call: call, Band: "2m", DistKm: 9650, Bearing: 40 + i*3,
|
||||
At: base.Add(time.Duration(i) * time.Minute),
|
||||
}, 47.0); op != nil {
|
||||
t.Fatalf("a 9650 km 2 m path was announced as an opening: %+v", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// But a real 2 m opening — an Es chain at a plausible distance — must survive.
|
||||
func TestLongButPlausibleTwoMetresStillCounts(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
base := time.Date(2026, 6, 20, 18, 0, 0, 0, time.UTC)
|
||||
var got *Opening
|
||||
for i, call := range []string{"EA1AA", "CT1BB", "EA7CC", "CT7DD"} {
|
||||
if op := d.Add(Spot{
|
||||
Call: call, Band: "2m", DistKm: 2000 + i*30, Bearing: 200 + i*4,
|
||||
At: base.Add(time.Duration(i) * time.Minute),
|
||||
}, 47.0); op != nil {
|
||||
got = op
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("a 2000 km 2 m burst in one sector was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
// Six metres keeps no ceiling: multi-hop Es genuinely goes that far, which is
|
||||
// why the flat limit was removed in the first place.
|
||||
func TestSixMetresHasNoCeiling(t *testing.T) {
|
||||
if MaxKmFor("6m") != 0 || MaxKmFor("10m") != 0 {
|
||||
t.Error("6 m and 10 m must have no distance ceiling")
|
||||
}
|
||||
if MaxKmFor("2m") == 0 {
|
||||
t.Error("2 m must have one")
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error {
|
||||
if k.curVFO == "B" {
|
||||
cmd = "FB"
|
||||
}
|
||||
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||
if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remember what we just commanded.
|
||||
//
|
||||
// While PTT is held the poll is skipped and State() hands back lastState — the
|
||||
// rig answers "?;" to IF; mid-transmission, and reading that as a fault used
|
||||
// to drop the whole link. But a frequency SET during that window then went
|
||||
// unrecorded, so the cache kept describing the dial as it was before.
|
||||
//
|
||||
// WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit,
|
||||
// and afterwards put it back. Polling during the over, it was told the rig was
|
||||
// still on the receive frequency — so there was nothing to put back, and the
|
||||
// dial stayed on the transmit frequency for good. Every following over
|
||||
// started from there, which is the drift that was reported.
|
||||
//
|
||||
// Only simplex is updated here. Under split, FreqHz means the transmit
|
||||
// frequency while this write lands on whichever VFO the operator is on, and
|
||||
// guessing which side moved would be worse than a stale value the next poll
|
||||
// corrects on its own.
|
||||
if !k.lastState.Split {
|
||||
k.curFreq = hz
|
||||
k.lastState.FreqHz = hz
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Kenwood) SetMode(mode string) error {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package cat
|
||||
|
||||
import "testing"
|
||||
|
||||
// WSJT-X "Fake It" against the transmit-window cache.
|
||||
//
|
||||
// Fake It keeps the radio on one dial frequency and shifts it only for the
|
||||
// duration of each over: set the transmit frequency, key, transmit, unkey, set
|
||||
// it back. The restore is not unconditional — WSJT-X reads the frequency back
|
||||
// and puts the dial where it believes it should be.
|
||||
//
|
||||
// That read lands inside the window where this backend deliberately stops
|
||||
// polling, because a Kenwood answers "?;" to IF; while it is transmitting and
|
||||
// treating that as a fault used to drop the whole shared link. The cache
|
||||
// answers instead. So the cache has to account for frequency SETS made during
|
||||
// the window, or it describes the dial as it was before the over — and WSJT-X,
|
||||
// told the radio is already on the receive frequency, has nothing to restore.
|
||||
//
|
||||
// This reproduces the sequence from a reported session: the dial stayed on the
|
||||
// transmit frequency after the first over and every later one started there.
|
||||
func TestKenwoodFakeItRestoresAfterTransmit(t *testing.T) {
|
||||
const (
|
||||
rxHz = 7074000 // where the operator is listening
|
||||
txHz = 7075500 // where Fake It moves the dial to transmit
|
||||
)
|
||||
|
||||
rig := &ts2000{vfoA: rxHz, mode: '2'}
|
||||
k := NewKenwood("COM-TEST", 9600, "FT8")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||
t.Fatalf("before the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||
}
|
||||
|
||||
// The over: shift the dial, then key.
|
||||
if err := k.SetFrequency(txHz); err != nil {
|
||||
t.Fatalf("set transmit frequency: %v", err)
|
||||
}
|
||||
if err := k.SetPTT(true); err != nil {
|
||||
t.Fatalf("ptt on: %v", err)
|
||||
}
|
||||
|
||||
// WSJT-X reads back mid-over. The wire is not polled here — this is the
|
||||
// cache talking, and it must not still be saying rxHz.
|
||||
s, err := k.ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("read during the over: %v", err)
|
||||
}
|
||||
if s.FreqHz != txHz {
|
||||
t.Errorf("during the over the backend reported %d, want %d — "+
|
||||
"reporting the pre-over frequency is what stops Fake It restoring the dial", s.FreqHz, txHz)
|
||||
}
|
||||
|
||||
if err := k.SetPTT(false); err != nil {
|
||||
t.Fatalf("ptt off: %v", err)
|
||||
}
|
||||
|
||||
// The restore, once the over is done.
|
||||
if err := k.SetFrequency(rxHz); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if rig.vfoA != rxHz {
|
||||
t.Errorf("dial left on %d after the over, want %d", rig.vfoA, rxHz)
|
||||
}
|
||||
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
|
||||
t.Errorf("after the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
|
||||
}
|
||||
}
|
||||
|
||||
// Under split the same write must NOT touch the cache: FreqHz means the
|
||||
// transmit frequency while the write lands on whichever VFO the operator is on,
|
||||
// so guessing which side moved would put a wrong number in front of the
|
||||
// operator. A stale one survives only until the next poll.
|
||||
func TestKenwoodSplitCacheLeftToThePoll(t *testing.T) {
|
||||
rig := &ts2000{vfoA: 14025000, vfoB: 14030000, mode: '3', split: true}
|
||||
k := NewKenwood("COM-TEST", 9600, "CW")
|
||||
k.dialPort = dialTo(rig)
|
||||
if err := k.Connect(); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer k.Disconnect()
|
||||
|
||||
s, err := k.ReadState()
|
||||
if err != nil || !s.Split {
|
||||
t.Fatalf("split not seen: %+v (err %v)", s, err)
|
||||
}
|
||||
before := s.FreqHz
|
||||
|
||||
if err := k.SetFrequency(14026000); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if k.lastState.FreqHz != before {
|
||||
t.Errorf("split cache moved to %d on a VFO write, want it left at %d for the poll",
|
||||
k.lastState.FreqHz, before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- The lookup cache gains the two fields the providers were already sending and
|
||||
-- nothing was reading: the operator's own site, and the postal code.
|
||||
--
|
||||
-- `web` matters beyond the cache: the qso table has had a `web` column since
|
||||
-- 0003 and no lookup ever filled it.
|
||||
ALTER TABLE callsign_cache ADD COLUMN web TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN zip TEXT;
|
||||
@@ -132,6 +132,18 @@ func (db *DB) Entities() []*Entity {
|
||||
func (db *DB) Lookup(callsign string) (Match, bool) {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
// Maritime and aeronautical mobile belong to NO entity, and that is not a
|
||||
// technicality — the station is at sea or in the air. RI1FJL/MM resolved to
|
||||
// Franz Josef Land while the expedition was still on its way there, telling
|
||||
// the operator they had worked an entity they had not.
|
||||
//
|
||||
// The previous choice was deliberate ("the log should still show the
|
||||
// operator's country") and it is wrong for exactly that reason: a home
|
||||
// country here is not extra information, it is a false claim about where the
|
||||
// contact happened.
|
||||
if IsMobileNoEntity(callsign) {
|
||||
return Match{}, false
|
||||
}
|
||||
call := normalizeCallsign(callsign)
|
||||
if call == "" {
|
||||
return Match{}, false
|
||||
@@ -364,3 +376,22 @@ func replaceAreaDigit(call string, d byte) string {
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
// IsMobileNoEntity reports a callsign DXCC assigns to no entity: a TRAILING
|
||||
// /MM (maritime mobile) or /AM (aeronautical mobile).
|
||||
//
|
||||
// Trailing only. A LEADING "MM" is the Scotland prefix and "AM" is Spain, so
|
||||
// MM0ABC and AM5X are ordinary calls — treating those as entity-less would be a
|
||||
// far larger error than the one this fixes.
|
||||
func IsMobileNoEntity(callsign string) bool {
|
||||
s := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
i := strings.LastIndex(s, "/")
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
switch s[i+1:] {
|
||||
case "MM", "AM":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+338
-338
@@ -4,348 +4,348 @@ package dxcc
|
||||
// numbers. Generated by joining cty.dat to the authoritative ARRL/ADIF entity
|
||||
// list (k0swe/dxcc-json) by primary prefix + canonical name. 344 entities.
|
||||
var dxccByName = map[string]int{
|
||||
"afghanistan": 3,
|
||||
"agalega & st. brandon": 4,
|
||||
"aland islands": 5,
|
||||
"alaska": 6,
|
||||
"albania": 7,
|
||||
"algeria": 400,
|
||||
"american samoa": 9,
|
||||
"afghanistan": 3,
|
||||
"agalega & st. brandon": 4,
|
||||
"aland islands": 5,
|
||||
"alaska": 6,
|
||||
"albania": 7,
|
||||
"algeria": 400,
|
||||
"american samoa": 9,
|
||||
"amsterdam & st. paul is.": 10,
|
||||
"andaman & nicobar is.": 11,
|
||||
"andorra": 203,
|
||||
"angola": 401,
|
||||
"anguilla": 12,
|
||||
"annobon island": 195,
|
||||
"antarctica": 13,
|
||||
"antigua & barbuda": 94,
|
||||
"argentina": 100,
|
||||
"armenia": 14,
|
||||
"aruba": 91,
|
||||
"ascension island": 205,
|
||||
"asiatic russia": 15,
|
||||
"asiatic turkey": 390,
|
||||
"austral islands": 508,
|
||||
"australia": 150,
|
||||
"austria": 206,
|
||||
"aves island": 17,
|
||||
"azerbaijan": 18,
|
||||
"azores": 149,
|
||||
"bahamas": 60,
|
||||
"bahrain": 304,
|
||||
"baker & howland islands": 20,
|
||||
"balearic islands": 21,
|
||||
"banaba island": 490,
|
||||
"bangladesh": 305,
|
||||
"barbados": 62,
|
||||
"bear island": 259,
|
||||
"belarus": 27,
|
||||
"belgium": 209,
|
||||
"belize": 66,
|
||||
"benin": 416,
|
||||
"bermuda": 64,
|
||||
"bhutan": 306,
|
||||
"bolivia": 104,
|
||||
"bonaire": 520,
|
||||
"bosnia-herzegovina": 501,
|
||||
"botswana": 402,
|
||||
"bouvet": 24,
|
||||
"brazil": 108,
|
||||
"british virgin islands": 65,
|
||||
"brunei darussalam": 345,
|
||||
"bulgaria": 212,
|
||||
"burkina faso": 480,
|
||||
"burundi": 404,
|
||||
"cambodia": 312,
|
||||
"cameroon": 406,
|
||||
"canada": 1,
|
||||
"canary islands": 29,
|
||||
"cape verde": 409,
|
||||
"cayman islands": 69,
|
||||
"andaman & nicobar is.": 11,
|
||||
"andorra": 203,
|
||||
"angola": 401,
|
||||
"anguilla": 12,
|
||||
"annobon island": 195,
|
||||
"antarctica": 13,
|
||||
"antigua & barbuda": 94,
|
||||
"argentina": 100,
|
||||
"armenia": 14,
|
||||
"aruba": 91,
|
||||
"ascension island": 205,
|
||||
"asiatic russia": 15,
|
||||
"asiatic turkey": 390,
|
||||
"austral islands": 508,
|
||||
"australia": 150,
|
||||
"austria": 206,
|
||||
"aves island": 17,
|
||||
"azerbaijan": 18,
|
||||
"azores": 149,
|
||||
"bahamas": 60,
|
||||
"bahrain": 304,
|
||||
"baker & howland islands": 20,
|
||||
"balearic islands": 21,
|
||||
"banaba island": 490,
|
||||
"bangladesh": 305,
|
||||
"barbados": 62,
|
||||
"bear island": 259,
|
||||
"belarus": 27,
|
||||
"belgium": 209,
|
||||
"belize": 66,
|
||||
"benin": 416,
|
||||
"bermuda": 64,
|
||||
"bhutan": 306,
|
||||
"bolivia": 104,
|
||||
"bonaire": 520,
|
||||
"bosnia-herzegovina": 501,
|
||||
"botswana": 402,
|
||||
"bouvet": 24,
|
||||
"brazil": 108,
|
||||
"british virgin islands": 65,
|
||||
"brunei darussalam": 345,
|
||||
"bulgaria": 212,
|
||||
"burkina faso": 480,
|
||||
"burundi": 404,
|
||||
"cambodia": 312,
|
||||
"cameroon": 406,
|
||||
"canada": 1,
|
||||
"canary islands": 29,
|
||||
"cape verde": 409,
|
||||
"cayman islands": 69,
|
||||
"central african republic": 408,
|
||||
"central kiribati": 31,
|
||||
"ceuta & melilla": 32,
|
||||
"chad": 410,
|
||||
"chagos islands": 33,
|
||||
"chatham islands": 34,
|
||||
"chesterfield islands": 512,
|
||||
"chile": 112,
|
||||
"china": 318,
|
||||
"christmas island": 35,
|
||||
"clipperton island": 36,
|
||||
"cocos (keeling) islands": 38,
|
||||
"cocos island": 37,
|
||||
"colombia": 116,
|
||||
"comoros": 411,
|
||||
"conway reef": 489,
|
||||
"corsica": 214,
|
||||
"costa rica": 308,
|
||||
"cote d'ivoire": 428,
|
||||
"crete": 40,
|
||||
"croatia": 497,
|
||||
"crozet island": 41,
|
||||
"cuba": 70,
|
||||
"curacao": 517,
|
||||
"cyprus": 215,
|
||||
"czech republic": 503,
|
||||
"dem. rep. of the congo": 414,
|
||||
"denmark": 221,
|
||||
"desecheo island": 43,
|
||||
"djibouti": 382,
|
||||
"dodecanese": 45,
|
||||
"dominica": 95,
|
||||
"dominican republic": 72,
|
||||
"dpr of korea": 344,
|
||||
"ducie island": 513,
|
||||
"east malaysia": 46,
|
||||
"easter island": 47,
|
||||
"eastern kiribati": 48,
|
||||
"ecuador": 120,
|
||||
"egypt": 478,
|
||||
"el salvador": 74,
|
||||
"england": 223,
|
||||
"equatorial guinea": 49,
|
||||
"eritrea": 51,
|
||||
"estonia": 52,
|
||||
"ethiopia": 53,
|
||||
"european russia": 54,
|
||||
"european turkey": 390,
|
||||
"falkland islands": 141,
|
||||
"faroe islands": 222,
|
||||
"fed. rep. of germany": 230,
|
||||
"fernando de noronha": 56,
|
||||
"fiji": 176,
|
||||
"finland": 224,
|
||||
"france": 227,
|
||||
"franz josef land": 61,
|
||||
"french guiana": 63,
|
||||
"french polynesia": 175,
|
||||
"gabon": 420,
|
||||
"galapagos islands": 71,
|
||||
"georgia": 75,
|
||||
"ghana": 424,
|
||||
"gibraltar": 233,
|
||||
"glorioso islands": 99,
|
||||
"greece": 236,
|
||||
"greenland": 237,
|
||||
"grenada": 77,
|
||||
"guadeloupe": 79,
|
||||
"guam": 103,
|
||||
"guantanamo bay": 105,
|
||||
"guatemala": 76,
|
||||
"guernsey": 106,
|
||||
"guinea": 107,
|
||||
"guinea-bissau": 109,
|
||||
"guyana": 129,
|
||||
"haiti": 78,
|
||||
"hawaii": 110,
|
||||
"heard island": 111,
|
||||
"honduras": 80,
|
||||
"hong kong": 321,
|
||||
"hungary": 239,
|
||||
"iceland": 242,
|
||||
"india": 324,
|
||||
"indonesia": 327,
|
||||
"iran": 330,
|
||||
"iraq": 333,
|
||||
"ireland": 245,
|
||||
"isle of man": 114,
|
||||
"israel": 336,
|
||||
"italy": 248,
|
||||
"itu hq": 117,
|
||||
"jamaica": 82,
|
||||
"jan mayen": 118,
|
||||
"japan": 339,
|
||||
"jersey": 122,
|
||||
"johnston island": 123,
|
||||
"jordan": 342,
|
||||
"juan de nova, europa": 124,
|
||||
"juan fernandez islands": 125,
|
||||
"kaliningrad": 126,
|
||||
"kazakhstan": 130,
|
||||
"kenya": 430,
|
||||
"kerguelen islands": 131,
|
||||
"kermadec islands": 133,
|
||||
"kingdom of eswatini": 468,
|
||||
"kure island": 138,
|
||||
"kuwait": 348,
|
||||
"kyrgyzstan": 135,
|
||||
"lakshadweep islands": 142,
|
||||
"laos": 143,
|
||||
"latvia": 145,
|
||||
"lebanon": 354,
|
||||
"lesotho": 432,
|
||||
"liberia": 434,
|
||||
"libya": 436,
|
||||
"liechtenstein": 251,
|
||||
"lithuania": 146,
|
||||
"lord howe island": 147,
|
||||
"luxembourg": 254,
|
||||
"macao": 152,
|
||||
"macquarie island": 153,
|
||||
"madagascar": 438,
|
||||
"madeira islands": 256,
|
||||
"malawi": 440,
|
||||
"maldives": 159,
|
||||
"mali": 442,
|
||||
"malpelo island": 161,
|
||||
"malta": 257,
|
||||
"mariana islands": 166,
|
||||
"market reef": 167,
|
||||
"marquesas islands": 509,
|
||||
"marshall islands": 168,
|
||||
"martinique": 84,
|
||||
"mauritania": 444,
|
||||
"mauritius": 165,
|
||||
"mayotte": 169,
|
||||
"mellish reef": 171,
|
||||
"mexico": 50,
|
||||
"micronesia": 173,
|
||||
"midway island": 174,
|
||||
"minami torishima": 177,
|
||||
"moldova": 179,
|
||||
"monaco": 260,
|
||||
"mongolia": 363,
|
||||
"montenegro": 514,
|
||||
"montserrat": 96,
|
||||
"morocco": 446,
|
||||
"mount athos": 180,
|
||||
"mozambique": 181,
|
||||
"myanmar": 309,
|
||||
"n.z. subantarctic is.": 16,
|
||||
"namibia": 464,
|
||||
"nauru": 157,
|
||||
"navassa island": 182,
|
||||
"nepal": 369,
|
||||
"netherlands": 263,
|
||||
"new caledonia": 162,
|
||||
"new zealand": 170,
|
||||
"nicaragua": 86,
|
||||
"niger": 187,
|
||||
"nigeria": 450,
|
||||
"niue": 188,
|
||||
"norfolk island": 189,
|
||||
"north cook islands": 191,
|
||||
"north macedonia": 502,
|
||||
"northern ireland": 265,
|
||||
"norway": 266,
|
||||
"ogasawara": 192,
|
||||
"oman": 370,
|
||||
"pakistan": 372,
|
||||
"palau": 22,
|
||||
"palestine": 510,
|
||||
"central kiribati": 31,
|
||||
"ceuta & melilla": 32,
|
||||
"chad": 410,
|
||||
"chagos islands": 33,
|
||||
"chatham islands": 34,
|
||||
"chesterfield islands": 512,
|
||||
"chile": 112,
|
||||
"china": 318,
|
||||
"christmas island": 35,
|
||||
"clipperton island": 36,
|
||||
"cocos (keeling) islands": 38,
|
||||
"cocos island": 37,
|
||||
"colombia": 116,
|
||||
"comoros": 411,
|
||||
"conway reef": 489,
|
||||
"corsica": 214,
|
||||
"costa rica": 308,
|
||||
"cote d'ivoire": 428,
|
||||
"crete": 40,
|
||||
"croatia": 497,
|
||||
"crozet island": 41,
|
||||
"cuba": 70,
|
||||
"curacao": 517,
|
||||
"cyprus": 215,
|
||||
"czech republic": 503,
|
||||
"dem. rep. of the congo": 414,
|
||||
"denmark": 221,
|
||||
"desecheo island": 43,
|
||||
"djibouti": 382,
|
||||
"dodecanese": 45,
|
||||
"dominica": 95,
|
||||
"dominican republic": 72,
|
||||
"dpr of korea": 344,
|
||||
"ducie island": 513,
|
||||
"east malaysia": 46,
|
||||
"easter island": 47,
|
||||
"eastern kiribati": 48,
|
||||
"ecuador": 120,
|
||||
"egypt": 478,
|
||||
"el salvador": 74,
|
||||
"england": 223,
|
||||
"equatorial guinea": 49,
|
||||
"eritrea": 51,
|
||||
"estonia": 52,
|
||||
"ethiopia": 53,
|
||||
"european russia": 54,
|
||||
"european turkey": 390,
|
||||
"falkland islands": 141,
|
||||
"faroe islands": 222,
|
||||
"fed. rep. of germany": 230,
|
||||
"fernando de noronha": 56,
|
||||
"fiji": 176,
|
||||
"finland": 224,
|
||||
"france": 227,
|
||||
"franz josef land": 61,
|
||||
"french guiana": 63,
|
||||
"french polynesia": 175,
|
||||
"gabon": 420,
|
||||
"galapagos islands": 71,
|
||||
"georgia": 75,
|
||||
"ghana": 424,
|
||||
"gibraltar": 233,
|
||||
"glorioso islands": 99,
|
||||
"greece": 236,
|
||||
"greenland": 237,
|
||||
"grenada": 77,
|
||||
"guadeloupe": 79,
|
||||
"guam": 103,
|
||||
"guantanamo bay": 105,
|
||||
"guatemala": 76,
|
||||
"guernsey": 106,
|
||||
"guinea": 107,
|
||||
"guinea-bissau": 109,
|
||||
"guyana": 129,
|
||||
"haiti": 78,
|
||||
"hawaii": 110,
|
||||
"heard island": 111,
|
||||
"honduras": 80,
|
||||
"hong kong": 321,
|
||||
"hungary": 239,
|
||||
"iceland": 242,
|
||||
"india": 324,
|
||||
"indonesia": 327,
|
||||
"iran": 330,
|
||||
"iraq": 333,
|
||||
"ireland": 245,
|
||||
"isle of man": 114,
|
||||
"israel": 336,
|
||||
"italy": 248,
|
||||
"itu hq": 117,
|
||||
"jamaica": 82,
|
||||
"jan mayen": 118,
|
||||
"japan": 339,
|
||||
"jersey": 122,
|
||||
"johnston island": 123,
|
||||
"jordan": 342,
|
||||
"juan de nova, europa": 124,
|
||||
"juan fernandez islands": 125,
|
||||
"kaliningrad": 126,
|
||||
"kazakhstan": 130,
|
||||
"kenya": 430,
|
||||
"kerguelen islands": 131,
|
||||
"kermadec islands": 133,
|
||||
"kingdom of eswatini": 468,
|
||||
"kure island": 138,
|
||||
"kuwait": 348,
|
||||
"kyrgyzstan": 135,
|
||||
"lakshadweep islands": 142,
|
||||
"laos": 143,
|
||||
"latvia": 145,
|
||||
"lebanon": 354,
|
||||
"lesotho": 432,
|
||||
"liberia": 434,
|
||||
"libya": 436,
|
||||
"liechtenstein": 251,
|
||||
"lithuania": 146,
|
||||
"lord howe island": 147,
|
||||
"luxembourg": 254,
|
||||
"macao": 152,
|
||||
"macquarie island": 153,
|
||||
"madagascar": 438,
|
||||
"madeira islands": 256,
|
||||
"malawi": 440,
|
||||
"maldives": 159,
|
||||
"mali": 442,
|
||||
"malpelo island": 161,
|
||||
"malta": 257,
|
||||
"mariana islands": 166,
|
||||
"market reef": 167,
|
||||
"marquesas islands": 509,
|
||||
"marshall islands": 168,
|
||||
"martinique": 84,
|
||||
"mauritania": 444,
|
||||
"mauritius": 165,
|
||||
"mayotte": 169,
|
||||
"mellish reef": 171,
|
||||
"mexico": 50,
|
||||
"micronesia": 173,
|
||||
"midway island": 174,
|
||||
"minami torishima": 177,
|
||||
"moldova": 179,
|
||||
"monaco": 260,
|
||||
"mongolia": 363,
|
||||
"montenegro": 514,
|
||||
"montserrat": 96,
|
||||
"morocco": 446,
|
||||
"mount athos": 180,
|
||||
"mozambique": 181,
|
||||
"myanmar": 309,
|
||||
"n.z. subantarctic is.": 16,
|
||||
"namibia": 464,
|
||||
"nauru": 157,
|
||||
"navassa island": 182,
|
||||
"nepal": 369,
|
||||
"netherlands": 263,
|
||||
"new caledonia": 162,
|
||||
"new zealand": 170,
|
||||
"nicaragua": 86,
|
||||
"niger": 187,
|
||||
"nigeria": 450,
|
||||
"niue": 188,
|
||||
"norfolk island": 189,
|
||||
"north cook islands": 191,
|
||||
"north macedonia": 502,
|
||||
"northern ireland": 265,
|
||||
"norway": 266,
|
||||
"ogasawara": 192,
|
||||
"oman": 370,
|
||||
"pakistan": 372,
|
||||
"palau": 22,
|
||||
"palestine": 510,
|
||||
"palmyra & jarvis islands": 197,
|
||||
"panama": 88,
|
||||
"papua new guinea": 163,
|
||||
"paraguay": 132,
|
||||
"peru": 136,
|
||||
"peter 1 island": 199,
|
||||
"philippines": 375,
|
||||
"pitcairn island": 172,
|
||||
"poland": 269,
|
||||
"portugal": 272,
|
||||
"pr. edward & marion is.": 201,
|
||||
"pratas island": 505,
|
||||
"puerto rico": 202,
|
||||
"qatar": 376,
|
||||
"republic of korea": 137,
|
||||
"republic of kosovo": 522,
|
||||
"republic of south sudan": 521,
|
||||
"republic of the congo": 412,
|
||||
"reunion island": 453,
|
||||
"revillagigedo": 204,
|
||||
"rodriguez island": 207,
|
||||
"romania": 275,
|
||||
"rotuma island": 460,
|
||||
"rwanda": 454,
|
||||
"saba & st. eustatius": 519,
|
||||
"sable island": 211,
|
||||
"samoa": 190,
|
||||
"panama": 88,
|
||||
"papua new guinea": 163,
|
||||
"paraguay": 132,
|
||||
"peru": 136,
|
||||
"peter 1 island": 199,
|
||||
"philippines": 375,
|
||||
"pitcairn island": 172,
|
||||
"poland": 269,
|
||||
"portugal": 272,
|
||||
"pr. edward & marion is.": 201,
|
||||
"pratas island": 505,
|
||||
"puerto rico": 202,
|
||||
"qatar": 376,
|
||||
"republic of korea": 137,
|
||||
"republic of kosovo": 522,
|
||||
"republic of south sudan": 521,
|
||||
"republic of the congo": 412,
|
||||
"reunion island": 453,
|
||||
"revillagigedo": 204,
|
||||
"rodriguez island": 207,
|
||||
"romania": 275,
|
||||
"rotuma island": 460,
|
||||
"rwanda": 454,
|
||||
"saba & st. eustatius": 519,
|
||||
"sable island": 211,
|
||||
"samoa": 190,
|
||||
"san andres & providencia": 216,
|
||||
"san felix & san ambrosio": 217,
|
||||
"san marino": 278,
|
||||
"sao tome & principe": 219,
|
||||
"sardinia": 225,
|
||||
"saudi arabia": 378,
|
||||
"scarborough reef": 506,
|
||||
"scotland": 279,
|
||||
"senegal": 456,
|
||||
"serbia": 296,
|
||||
"seychelles": 379,
|
||||
"shetland islands": 279,
|
||||
"sierra leone": 458,
|
||||
"singapore": 381,
|
||||
"sint maarten": 518,
|
||||
"slovak republic": 504,
|
||||
"slovenia": 499,
|
||||
"solomon islands": 185,
|
||||
"somalia": 232,
|
||||
"south africa": 462,
|
||||
"south cook islands": 234,
|
||||
"south georgia island": 235,
|
||||
"south orkney islands": 238,
|
||||
"south sandwich islands": 240,
|
||||
"south shetland islands": 241,
|
||||
"sov mil order of malta": 246,
|
||||
"spain": 281,
|
||||
"spratly islands": 247,
|
||||
"sri lanka": 315,
|
||||
"st. barthelemy": 516,
|
||||
"st. helena": 250,
|
||||
"st. kitts & nevis": 249,
|
||||
"st. lucia": 97,
|
||||
"st. martin": 213,
|
||||
"st. paul island": 252,
|
||||
"st. peter & st. paul": 253,
|
||||
"st. pierre & miquelon": 277,
|
||||
"st. vincent": 98,
|
||||
"sudan": 466,
|
||||
"suriname": 140,
|
||||
"svalbard": 259,
|
||||
"swains island": 515,
|
||||
"sweden": 284,
|
||||
"switzerland": 287,
|
||||
"syria": 384,
|
||||
"taiwan": 386,
|
||||
"tajikistan": 262,
|
||||
"tanzania": 470,
|
||||
"temotu province": 507,
|
||||
"thailand": 387,
|
||||
"the gambia": 422,
|
||||
"timor - leste": 511,
|
||||
"togo": 483,
|
||||
"tokelau islands": 270,
|
||||
"tonga": 160,
|
||||
"trindade & martim vaz": 273,
|
||||
"trinidad & tobago": 90,
|
||||
"san marino": 278,
|
||||
"sao tome & principe": 219,
|
||||
"sardinia": 225,
|
||||
"saudi arabia": 378,
|
||||
"scarborough reef": 506,
|
||||
"scotland": 279,
|
||||
"senegal": 456,
|
||||
"serbia": 296,
|
||||
"seychelles": 379,
|
||||
"shetland islands": 279,
|
||||
"sierra leone": 458,
|
||||
"singapore": 381,
|
||||
"sint maarten": 518,
|
||||
"slovak republic": 504,
|
||||
"slovenia": 499,
|
||||
"solomon islands": 185,
|
||||
"somalia": 232,
|
||||
"south africa": 462,
|
||||
"south cook islands": 234,
|
||||
"south georgia island": 235,
|
||||
"south orkney islands": 238,
|
||||
"south sandwich islands": 240,
|
||||
"south shetland islands": 241,
|
||||
"sov mil order of malta": 246,
|
||||
"spain": 281,
|
||||
"spratly islands": 247,
|
||||
"sri lanka": 315,
|
||||
"st. barthelemy": 516,
|
||||
"st. helena": 250,
|
||||
"st. kitts & nevis": 249,
|
||||
"st. lucia": 97,
|
||||
"st. martin": 213,
|
||||
"st. paul island": 252,
|
||||
"st. peter & st. paul": 253,
|
||||
"st. pierre & miquelon": 277,
|
||||
"st. vincent": 98,
|
||||
"sudan": 466,
|
||||
"suriname": 140,
|
||||
"svalbard": 259,
|
||||
"swains island": 515,
|
||||
"sweden": 284,
|
||||
"switzerland": 287,
|
||||
"syria": 384,
|
||||
"taiwan": 386,
|
||||
"tajikistan": 262,
|
||||
"tanzania": 470,
|
||||
"temotu province": 507,
|
||||
"thailand": 387,
|
||||
"the gambia": 422,
|
||||
"timor - leste": 511,
|
||||
"togo": 483,
|
||||
"tokelau islands": 270,
|
||||
"tonga": 160,
|
||||
"trindade & martim vaz": 273,
|
||||
"trinidad & tobago": 90,
|
||||
"tristan da cunha & gough": 274,
|
||||
"tromelin island": 276,
|
||||
"tunisia": 474,
|
||||
"turkmenistan": 280,
|
||||
"turks & caicos islands": 89,
|
||||
"tuvalu": 282,
|
||||
"uganda": 286,
|
||||
"uk base areas on cyprus": 283,
|
||||
"ukraine": 288,
|
||||
"united arab emirates": 391,
|
||||
"united nations hq": 289,
|
||||
"united states": 291,
|
||||
"uruguay": 144,
|
||||
"us virgin islands": 285,
|
||||
"uzbekistan": 292,
|
||||
"vanuatu": 158,
|
||||
"vatican city": 295,
|
||||
"venezuela": 148,
|
||||
"vienna intl ctr": 206,
|
||||
"vietnam": 293,
|
||||
"wake island": 297,
|
||||
"wales": 294,
|
||||
"wallis & futuna islands": 298,
|
||||
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
|
||||
"western kiribati": 301,
|
||||
"western sahara": 302,
|
||||
"willis island": 303,
|
||||
"yemen": 492,
|
||||
"zambia": 482,
|
||||
"zimbabwe": 452,
|
||||
"tromelin island": 276,
|
||||
"tunisia": 474,
|
||||
"turkmenistan": 280,
|
||||
"turks & caicos islands": 89,
|
||||
"tuvalu": 282,
|
||||
"uganda": 286,
|
||||
"uk base areas on cyprus": 283,
|
||||
"ukraine": 288,
|
||||
"united arab emirates": 391,
|
||||
"united nations hq": 289,
|
||||
"united states": 291,
|
||||
"uruguay": 144,
|
||||
"us virgin islands": 285,
|
||||
"uzbekistan": 292,
|
||||
"vanuatu": 158,
|
||||
"vatican city": 295,
|
||||
"venezuela": 148,
|
||||
"vienna intl ctr": 206,
|
||||
"vietnam": 293,
|
||||
"wake island": 297,
|
||||
"wales": 294,
|
||||
"wallis & futuna islands": 298,
|
||||
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
|
||||
"western kiribati": 301,
|
||||
"western sahara": 302,
|
||||
"willis island": 303,
|
||||
"yemen": 492,
|
||||
"zambia": 482,
|
||||
"zimbabwe": 452,
|
||||
}
|
||||
|
||||
+21
-21
@@ -178,9 +178,9 @@ Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
|
||||
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
|
||||
"IT9CLY": "Italy",
|
||||
"IG9A": "Italy", // African Italy (*IG9) → Italy
|
||||
"IG9A": "Italy", // African Italy (*IG9) → Italy
|
||||
"IK0ABC": "Italy",
|
||||
"IS0XYZ": "Sardinia", // real DXCC entity — must stay Sardinia
|
||||
"IM0ABC": "Sardinia",
|
||||
@@ -210,25 +210,25 @@ Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"F4BPO": "F4BPO",
|
||||
"f4bpo": "F4BPO",
|
||||
" F4BPO ": "F4BPO",
|
||||
"F4BPO/P": "F4BPO",
|
||||
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||
"DL/F4BPO": "DL",
|
||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||
"MM/LY3X/P": "MM",
|
||||
"F4BPO/W6": "W6",
|
||||
"VK9/F4BPO": "VK9",
|
||||
"4U1UN/B": "4U1UN", // trailing /B = beacon → strip, keep the base call
|
||||
"B/F4BPO": "B", // leading B = China operating prefix, NOT a beacon
|
||||
"F4BPO": "F4BPO",
|
||||
"f4bpo": "F4BPO",
|
||||
" F4BPO ": "F4BPO",
|
||||
"F4BPO/P": "F4BPO",
|
||||
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||
"DL/F4BPO": "DL",
|
||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||
"MM/LY3X/P": "MM",
|
||||
"F4BPO/W6": "W6",
|
||||
"VK9/F4BPO": "VK9",
|
||||
"4U1UN/B": "4U1UN", // trailing /B = beacon → strip, keep the base call
|
||||
"B/F4BPO": "B", // leading B = China operating prefix, NOT a beacon
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeCallsign(in); got != want {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package dxcc
|
||||
|
||||
import "testing"
|
||||
|
||||
// /MM and /AM belong to NO DXCC entity: the station is at sea or in the air.
|
||||
//
|
||||
// This is not a technicality. RI1FJL/MM resolved to Franz Josef Land while the
|
||||
// expedition was still sailing there, which told the operator they had worked
|
||||
// an entity they had not.
|
||||
func TestMobileNoEntity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
call string
|
||||
want bool
|
||||
}{
|
||||
{"RI1FJL/MM", true},
|
||||
{"YB1SCY/AM", true},
|
||||
{"ri1fjl/mm", true},
|
||||
{" RI1FJL/MM ", true},
|
||||
// A LEADING MM is Scotland and AM is Spain. Treating those as
|
||||
// entity-less would be a far larger error than the one this fixes.
|
||||
{"MM0ABC", false},
|
||||
{"AM5X", false},
|
||||
{"MM/F4BPO", false},
|
||||
{"AM/DL1ABC", false},
|
||||
// Other suffixes keep their entity — a portable station is still ashore.
|
||||
{"F4BPO/P", false},
|
||||
{"F4BPO/M", false},
|
||||
{"4U1UN/B", false},
|
||||
{"F4BPO", false},
|
||||
// Only the LAST element counts: RI1FJL/MM/P is still maritime, and a
|
||||
// call whose /MM is not final is not.
|
||||
{"RI1FJL/MM/P", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsMobileNoEntity(tc.call); got != tc.want {
|
||||
t.Errorf("IsMobileNoEntity(%q) = %v, want %v", tc.call, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -4,6 +4,7 @@ package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
@@ -85,7 +86,32 @@ func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
if err := client.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
return fmt.Errorf("send via %s:%d (%s, %s): %w",
|
||||
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// describeSize reports what was attached, in bytes.
|
||||
//
|
||||
// "An existing connection was forcibly closed" during DATA is the same message
|
||||
// whether the server refused the size, hit a quota, or simply dropped the
|
||||
// socket — and the first of those is the only one the operator can act on
|
||||
// directly. Naming the size rules it in or out without a second attempt.
|
||||
func describeSize(paths []string) string {
|
||||
var total int64
|
||||
n := 0
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
total += fi.Size()
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return "no attachment"
|
||||
}
|
||||
return fmt.Sprintf("%d attachment(s), %d KB", n, (total+1023)/1024)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "An existing connection was forcibly closed" during DATA reads the same
|
||||
// whether the server refused the SIZE, hit a quota, or just dropped the socket
|
||||
// — and only the first is something the operator can act on. Naming the size in
|
||||
// the error rules it in or out without a second attempt.
|
||||
func TestDescribeSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "card.jpg")
|
||||
if err := os.WriteFile(a, make([]byte, 2048), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := describeSize(nil); got != "no attachment" {
|
||||
t.Errorf("no attachment = %q", got)
|
||||
}
|
||||
if got := describeSize([]string{""}); got != "no attachment" {
|
||||
t.Errorf("blank path = %q, want it ignored", got)
|
||||
}
|
||||
if got := describeSize([]string{a}); got != "1 attachment(s), 2 KB" {
|
||||
t.Errorf("one file = %q", got)
|
||||
}
|
||||
// A path that does not exist must not be counted — the mail library skips it,
|
||||
// so reporting it would describe a message that was never sent.
|
||||
if got := describeSize([]string{a, filepath.Join(dir, "gone.jpg")}); got != "1 attachment(s), 2 KB" {
|
||||
t.Errorf("missing file counted: %q", got)
|
||||
}
|
||||
}
|
||||
+25
-17
@@ -65,23 +65,25 @@ const (
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
// OnAir: HRDLog only — publish the live frequency, mode and rig on the site.
|
||||
OnAir bool `json:"on_air"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
}
|
||||
|
||||
// normalised returns the config with whitespace trimmed and a valid upload
|
||||
@@ -142,4 +144,10 @@ type UploadResult struct {
|
||||
OK bool // the service accepted (or already had) the QSO
|
||||
LogID string // service-assigned record id, when provided
|
||||
Message string // human-readable detail (reason on failure)
|
||||
// Ignored is set when the service accepted the submission but left some or
|
||||
// all of the QSOs out of it. TQSL does this for contacts already uploaded
|
||||
// AND for contacts outside the certificate's date range, and reports both
|
||||
// with the same exit code — so the caller must show the message rather than
|
||||
// decide on its own that everything went through.
|
||||
Ignored bool
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -143,3 +144,59 @@ func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (st
|
||||
}
|
||||
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
||||
}
|
||||
|
||||
// hrdlogOnAirURL is HRDLog's live-status endpoint — what puts "F4BPO is on air
|
||||
// 14,074,000 FM IC-7300" on the site's front page.
|
||||
//
|
||||
// Endpoint and field names taken from HRDLog's own library (github.com/iw1qlh/
|
||||
// HRDLOG-net-library, HrdProtocol.SendOnAirAsync), not from guesswork: it posts
|
||||
// Frequency in Hz, Mode, Radio, Callsign, Code and App.
|
||||
//
|
||||
// HTTPS where that library uses plain HTTP. The upload code is a credential and
|
||||
// has no business crossing the network in clear; the sibling NewEntry endpoint
|
||||
// on the same host already serves TLS.
|
||||
const hrdlogOnAirURL = "https://robot.hrdlog.net/OnAir.aspx"
|
||||
|
||||
// SendHRDLogOnAir publishes the current frequency, mode and rig.
|
||||
//
|
||||
// Deliberately fire-and-forget in spirit: it is a status broadcast, so a
|
||||
// failure is logged and never surfaced as an error the operator must clear —
|
||||
// but the message is returned so the caller can log WHAT the site said rather
|
||||
// than only that something went wrong.
|
||||
func SendHRDLogOnAir(ctx context.Context, client *http.Client, callsign, code string, freqHz int64, mode, radio string) (string, error) {
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
code = strings.TrimSpace(code)
|
||||
if callsign == "" || code == "" {
|
||||
return "", fmt.Errorf("hrdlog: callsign and upload code required")
|
||||
}
|
||||
if freqHz <= 0 {
|
||||
return "", fmt.Errorf("hrdlog: no frequency to announce")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("Callsign", callsign)
|
||||
form.Set("Code", code)
|
||||
form.Set("App", hrdlogApp)
|
||||
form.Set("Frequency", strconv.FormatInt(freqHz, 10))
|
||||
form.Set("Mode", strings.TrimSpace(mode))
|
||||
form.Set("Radio", strings.TrimSpace(radio))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogOnAirURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hrdlog on-air: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hrdlog on-air: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return msg, fmt.Errorf("hrdlog on-air: http %d", resp.StatusCode)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
+51
-4
@@ -178,8 +178,12 @@ func fileExists(p string) bool {
|
||||
//
|
||||
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
||||
//
|
||||
// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of
|
||||
// range); 9 = some uploaded, some skipped; anything else = failure.
|
||||
// Exit codes are TQSL's own (see the table in its cmdline help). 8 and 9 are
|
||||
// the ones that matter and both used to be read as plain success: 8 means NO
|
||||
// QSOs were processed and 9 means some were left out — in each case because
|
||||
// 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"
|
||||
// while LoTW had never seen it.
|
||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||
loc := strings.TrimSpace(cfg.StationLocation)
|
||||
@@ -249,11 +253,31 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
||||
}
|
||||
}
|
||||
|
||||
// TQSL's exit codes, from its own cmdline documentation. Two of them used to
|
||||
// be read as plain success, and that is how contacts came to be stamped
|
||||
// "uploaded" while LoTW had never seen them:
|
||||
//
|
||||
// 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE
|
||||
// 9 some processed, some ignored — same two reasons
|
||||
// 14 some already uploaded, the rest signed
|
||||
//
|
||||
// "Out of date range" is the one that bites: a contact older than the
|
||||
// callsign certificate's validity is silently left out, and reporting that as
|
||||
// success stamped it sent for ever. TQSL says which case it is in its output,
|
||||
// so the message is carried up rather than replaced with a guess.
|
||||
switch code {
|
||||
case 0, 9:
|
||||
case 0:
|
||||
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
||||
case 9, 14:
|
||||
return UploadResult{OK: true, Ignored: true, Message: tqslDetail(msg,
|
||||
"uploaded — but TQSL left some contacts out (already uploaded, or outside the certificate's date range)")}, nil
|
||||
case 8:
|
||||
return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil
|
||||
// Nothing reached LoTW. NOT stamped as sent: a duplicate left at "R" is
|
||||
// harmless and will be refused again, while a contact wrongly marked sent
|
||||
// is one the operator will never think to look at again.
|
||||
return UploadResult{OK: false, Ignored: true, Message: tqslDetail(msg,
|
||||
"TQSL uploaded nothing — every contact was already uploaded, or outside the certificate's date range")},
|
||||
fmt.Errorf("lotw: no QSOs processed")
|
||||
default:
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("tqsl exit code %d", code)
|
||||
@@ -262,6 +286,29 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
||||
}
|
||||
}
|
||||
|
||||
// tqslDetail keeps the lines of TQSL's own output that say what happened to the
|
||||
// contacts, and appends the summary.
|
||||
//
|
||||
// TQSL is explicit — "414 QSO records were already uploaded", "N QSO records
|
||||
// are out of date range" — and that sentence is the whole answer to "why is my
|
||||
// contact not on LoTW". It used to be captured and thrown away.
|
||||
func tqslDetail(out, summary string) string {
|
||||
var keep []string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
l := strings.ToLower(ln)
|
||||
if strings.Contains(l, "qso") && (strings.Contains(l, "already uploaded") ||
|
||||
strings.Contains(l, "date range") || strings.Contains(l, "ignored") ||
|
||||
strings.Contains(l, "duplicate")) {
|
||||
keep = append(keep, ln)
|
||||
}
|
||||
}
|
||||
if len(keep) == 0 {
|
||||
return summary
|
||||
}
|
||||
return summary + " — " + strings.Join(keep, "; ")
|
||||
}
|
||||
|
||||
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
||||
// location exists in station_data.
|
||||
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package extsvc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TQSL's own exit codes, from its cmdline documentation:
|
||||
//
|
||||
// 0 success
|
||||
// 8 NO QSOs were processed — already uploaded OR out of date range
|
||||
// 9 some processed, some ignored — same two reasons
|
||||
// 14 some already uploaded, the rest signed
|
||||
//
|
||||
// 8 and 9 both used to be read as plain success, and that is how a contact came
|
||||
// to be stamped "uploaded to LoTW" while LoTW had never seen it. The one that
|
||||
// bites is "out of date range": a contact older than the callsign certificate's
|
||||
// validity is silently left out of the submission.
|
||||
func TestTQSLDetailKeepsTheReason(t *testing.T) {
|
||||
out := `13:22:01: Signing using Callsign G0ABC, DXCC Entity ENGLAND
|
||||
13:22:02: /tmp/x.adi: 20 QSO records are out of date range
|
||||
13:22:02: Final Status: No QSOs were processed (8)`
|
||||
got := tqslDetail(out, "summary")
|
||||
if got == "summary" {
|
||||
t.Fatal("threw away TQSL's explanation — that sentence is the whole answer to \"why is my contact not on LoTW\"")
|
||||
}
|
||||
if !contains(got, "out of date range") {
|
||||
t.Errorf("the reason was lost: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTQSLDetailFallsBackToTheSummary(t *testing.T) {
|
||||
if got := tqslDetail("nothing useful here", "summary"); got != "summary" {
|
||||
t.Errorf("got %q, want the plain summary when TQSL said nothing specific", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(h, n string) bool {
|
||||
return len(h) >= len(n) && (func() bool {
|
||||
for i := 0; i+len(n) <= len(h); i++ {
|
||||
if h[i:i+len(n)] == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -67,3 +67,54 @@ func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
||||
}
|
||||
return HaversineKm(lat1, lon1, lat2, lon2), true
|
||||
}
|
||||
|
||||
// LatLonToGrid returns the 4-character Maidenhead square for a position.
|
||||
func LatLonToGrid(lat, lon float64) string {
|
||||
lon = math.Mod(lon+180, 360)
|
||||
if lon < 0 {
|
||||
lon += 360
|
||||
}
|
||||
lat = lat + 90
|
||||
if lat < 0 {
|
||||
lat = 0
|
||||
} else if lat > 180 {
|
||||
lat = 180
|
||||
}
|
||||
return string([]byte{
|
||||
byte('A' + int(lon/20)),
|
||||
byte('A' + int(lat/10)),
|
||||
byte('0' + int(math.Mod(lon, 20)/2)),
|
||||
byte('0' + int(math.Mod(lat, 10)/1)),
|
||||
})
|
||||
}
|
||||
|
||||
// NeighbourGrids returns the square holding (lat, lon) and the ring of squares
|
||||
// around it — 9 squares for ring 1, 25 for ring 2.
|
||||
//
|
||||
// Used to filter the PSK Reporter feed at the BROKER rather than in OpsLog. A
|
||||
// square is about 111 km tall and 150 km wide at mid latitudes, so one ring is
|
||||
// roughly the 300 km "around here" the feed already meant, and the traffic that
|
||||
// used to be received and discarded is never sent.
|
||||
func NeighbourGrids(lat, lon float64, ring int) []string {
|
||||
if ring < 0 {
|
||||
ring = 0
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for dLat := -ring; dLat <= ring; dLat++ {
|
||||
for dLon := -ring; dLon <= ring; dLon++ {
|
||||
// One square step: 1° of latitude, 2° of longitude.
|
||||
la := lat + float64(dLat)
|
||||
lo := lon + float64(dLon)*2
|
||||
if la > 90 || la < -90 {
|
||||
continue // past a pole there is no square, not a wrapped one
|
||||
}
|
||||
g := LatLonToGrid(la, lo)
|
||||
if !seen[g] {
|
||||
seen[g] = true
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package geo
|
||||
|
||||
import "testing"
|
||||
|
||||
// The squares the PSK Reporter feed is filtered on. A wrong ring means either
|
||||
// receiving the world again or hearing nothing.
|
||||
func TestNeighbourGrids(t *testing.T) {
|
||||
// JN36 is around 46.5N 5.5E.
|
||||
lat, lon, ok := GridToLatLon("JN36")
|
||||
if !ok {
|
||||
t.Fatal("JN36 did not resolve")
|
||||
}
|
||||
if got := LatLonToGrid(lat, lon); got != "JN36" {
|
||||
t.Fatalf("round trip gave %q, want JN36", got)
|
||||
}
|
||||
|
||||
ring := NeighbourGrids(lat, lon, 1)
|
||||
if len(ring) != 9 {
|
||||
t.Errorf("ring 1 has %d squares, want 9: %v", len(ring), ring)
|
||||
}
|
||||
found := false
|
||||
for _, g := range ring {
|
||||
if g == "JN36" {
|
||||
found = true
|
||||
}
|
||||
if len(g) != 4 {
|
||||
t.Errorf("not a 4-character square: %q", g)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the operator's own square is missing from %v", ring)
|
||||
}
|
||||
if n := len(NeighbourGrids(lat, lon, 0)); n != 1 {
|
||||
t.Errorf("ring 0 has %d squares, want just the operator's", n)
|
||||
}
|
||||
if n := len(NeighbourGrids(lat, lon, 2)); n != 25 {
|
||||
t.Errorf("ring 2 has %d squares, want 25", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Near a pole a step north has nowhere to go; it must be dropped, not wrapped
|
||||
// onto a square on the far side of the world.
|
||||
func TestNeighbourGridsNearThePole(t *testing.T) {
|
||||
for _, g := range NeighbourGrids(89.5, 25, 1) {
|
||||
if len(g) != 4 {
|
||||
t.Errorf("bad square near the pole: %q", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package gridcache is the long-term callsign→grid store behind grid chasing.
|
||||
//
|
||||
// Its own SQLite file, not a table in the settings database: that one sits
|
||||
// wherever the operator put it, often a synchronised folder, and this rewrites
|
||||
// itself every minute. Deleting the file costs a few days of listening.
|
||||
//
|
||||
// A callsign has one grid and the newest report wins — a stale locator reads as
|
||||
// a square already worked, which is worse than none.
|
||||
package gridcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Retention bounds a store that would otherwise only ever grow. Two years is
|
||||
// chosen to be far longer than any propagation interest and short enough that a
|
||||
// reassigned callsign eventually stops carrying its previous holder's square —
|
||||
// the one way this cache can be actively wrong rather than merely empty.
|
||||
const Retention = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// FlushEvery is the batch interval. Long enough that a burst of reports costs
|
||||
// one transaction, short enough that a crash loses a minute of learning.
|
||||
const FlushEvery = 60 * time.Second
|
||||
|
||||
// Sources a locator can come from.
|
||||
const (
|
||||
SourceDecode = "decode" // a CQ this station's own receiver decoded
|
||||
SourceMQTT = "mqtt" // a PSK Reporter report
|
||||
)
|
||||
|
||||
// Entry is one locator and where it came from.
|
||||
type Entry struct {
|
||||
Grid string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
|
||||
mu sync.Mutex
|
||||
dirty map[string]Entry // call → what to write
|
||||
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
|
||||
logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Open creates or opens the store at path and prunes what has aged out.
|
||||
func Open(path string, logf func(string, ...any)) (*Store, error) {
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
// WAL so a flush never blocks a read, and a busy timeout because the flush
|
||||
// goroutine and the startup load can overlap on a slow disk.
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gridcache: open %s: %w", path, err)
|
||||
}
|
||||
if _, err := db.Exec("CREATE TABLE IF NOT EXISTS grids (" +
|
||||
"call TEXT PRIMARY KEY, grid TEXT NOT NULL, updated_at INTEGER NOT NULL, " +
|
||||
"source TEXT NOT NULL DEFAULT '')"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("gridcache: schema: %w", err)
|
||||
}
|
||||
// A file written before the column existed keeps its rows; this fails
|
||||
// harmlessly when the column is already there.
|
||||
db.Exec("ALTER TABLE grids ADD COLUMN source TEXT NOT NULL DEFAULT ''")
|
||||
s := &Store{db: db, dirty: map[string]Entry{}, stop: make(chan struct{}), logf: logf}
|
||||
if n, err := s.prune(); err != nil {
|
||||
s.logf("gridcache: prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
s.logf("gridcache: pruned %d locators not heard in %d days", n, int(Retention.Hours()/24))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) prune() (int64, error) {
|
||||
cut := time.Now().Add(-Retention).Unix()
|
||||
res, err := s.db.Exec(`DELETE FROM grids WHERE updated_at < ?`, cut)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// LoadAll returns every stored locator, for seeding the in-memory map at
|
||||
// startup. One query and one map build — the point of the whole package is that
|
||||
// nothing afterwards has to ask the database anything.
|
||||
func (s *Store) LoadAll() (map[string]string, error) {
|
||||
rows, err := s.db.Query(`SELECT call, grid FROM grids`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gridcache: load: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]string, 4096)
|
||||
for rows.Next() {
|
||||
var call, grid string
|
||||
if err := rows.Scan(&call, &grid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[call] = grid
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Put queues a locator for writing. Callers pass only what CHANGED — an
|
||||
// unchanged report is the common case by a wide margin and must not reach here,
|
||||
// or the batch would carry the whole feed instead of the news in it.
|
||||
func (s *Store) Put(call, grid, source string) {
|
||||
call = strings.ToUpper(strings.TrimSpace(call))
|
||||
grid = strings.TrimSpace(grid)
|
||||
if call == "" || grid == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.dirty[call] = Entry{Grid: grid, Source: source}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start runs the flush loop until Close.
|
||||
func (s *Store) Start(ctx context.Context) {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
t := time.NewTicker(FlushEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.Flush(); err != nil {
|
||||
s.logf("gridcache: flush failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Flush writes the pending batch in one transaction. Safe to call with nothing
|
||||
// pending, which is most of the time on a quiet band.
|
||||
func (s *Store) Flush() error {
|
||||
s.mu.Lock()
|
||||
if len(s.dirty) == 0 {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
batch := s.dirty
|
||||
s.dirty = map[string]Entry{}
|
||||
s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
st, err := tx.Prepare(`INSERT INTO grids (call, grid, updated_at, source) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(call) DO UPDATE SET grid = excluded.grid, updated_at = excluded.updated_at,
|
||||
source = excluded.source`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
now := time.Now().Unix()
|
||||
for call, e := range batch {
|
||||
if _, err := st.Exec(call, e.Grid, now, e.Source); err != nil {
|
||||
tx.Rollback()
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.requeue(batch)
|
||||
return err
|
||||
}
|
||||
// Logged because a batch that never reaches the disk looks exactly like one
|
||||
// that does: the locators are in memory either way until the next restart,
|
||||
// which is the one moment the difference shows.
|
||||
s.logf("gridcache: wrote %d locators", len(batch))
|
||||
return nil
|
||||
}
|
||||
|
||||
// requeue puts a failed batch back, without overwriting anything learnt while it
|
||||
// was in flight — the newer value is the right one.
|
||||
func (s *Store) requeue(batch map[string]Entry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for call, e := range batch {
|
||||
if _, newer := s.dirty[call]; !newer {
|
||||
s.dirty[call] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pending reports how many locators are waiting to be written.
|
||||
func (s *Store) Pending() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.dirty)
|
||||
}
|
||||
|
||||
// Close stops the loop and writes whatever is pending. A restart is the moment
|
||||
// the cache is most valuable, so losing the last minute of learning to a clean
|
||||
// shutdown would be a poor trade.
|
||||
func (s *Store) Close() error {
|
||||
s.stopOnce.Do(func() { close(s.stop) })
|
||||
s.wg.Wait()
|
||||
err := s.Flush()
|
||||
if cerr := s.db.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package gridcache
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func open(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "grids.db")
|
||||
s, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
// The whole reason the store exists: what was learnt is still there after a
|
||||
// restart, so the cluster's locator column is full in the first second instead
|
||||
// of after an hour of listening.
|
||||
func TestSurvivesRestart(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("F4BPO", "JN36", SourceDecode)
|
||||
s.Put("OH5CX", "KP30", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, err := again.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got["F4BPO"] != "JN36" || got["OH5CX"] != "KP30" {
|
||||
t.Errorf("locators lost across a restart: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A callsign has ONE grid and the newest report wins. Operators move, go
|
||||
// portable, go on expedition — and a stale locator is worse than none for grid
|
||||
// chasing, because it reads as a square already worked.
|
||||
func TestNewestReportWins(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
s.Put("F4BPO", "JN36", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Put("F4BPO", "KP30", SourceDecode) // moved
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.LoadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["F4BPO"] != "KP30" {
|
||||
t.Errorf("grid = %q, want the newer KP30", got["F4BPO"])
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("a callsign must hold one row, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing reaches the disk until a flush, and a flush with nothing pending is
|
||||
// not an error — that is most minutes on a quiet band.
|
||||
func TestBatching(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
defer s.Close()
|
||||
|
||||
for _, c := range []string{"A1AA", "B2BB", "C3CC"} {
|
||||
s.Put(c, "JN36", SourceDecode)
|
||||
}
|
||||
if n := s.Pending(); n != 3 {
|
||||
t.Errorf("pending = %d, want 3 queued and unwritten", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 0 {
|
||||
t.Errorf("wrote before the flush: %v", got)
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := s.Pending(); n != 0 {
|
||||
t.Errorf("pending = %d after a flush, want 0", n)
|
||||
}
|
||||
if got, _ := s.LoadAll(); len(got) != 3 {
|
||||
t.Errorf("flush wrote %d rows, want 3", len(got))
|
||||
}
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Errorf("empty flush must be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Age is what bounds a store that would otherwise only grow. A callsign not
|
||||
// heard in two years is likely reassigned, and carrying its previous holder's
|
||||
// square is the one way this cache can be actively wrong rather than empty.
|
||||
func TestPruneOnOpen(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("FRESH", "JN36", SourceDecode)
|
||||
s.Put("STALE", "IO91", SourceDecode)
|
||||
if err := s.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate one row past the retention window.
|
||||
old := time.Now().Add(-Retention - 24*time.Hour).Unix()
|
||||
if _, err := s.db.Exec(`UPDATE grids SET updated_at = ? WHERE call = 'STALE'`, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if _, ok := got["STALE"]; ok {
|
||||
t.Error("an entry past the retention window survived — the store is unbounded")
|
||||
}
|
||||
if got["FRESH"] != "JN36" {
|
||||
t.Error("pruning took a live entry with it")
|
||||
}
|
||||
}
|
||||
|
||||
// Close has to write what the last minute learnt: a restart is exactly when the
|
||||
// cache is worth the most, so losing it to a clean shutdown would be a poor
|
||||
// trade.
|
||||
func TestCloseFlushes(t *testing.T) {
|
||||
s, path := open(t)
|
||||
s.Put("LATE", "JN36", SourceDecode)
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
again, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
got, _ := again.LoadAll()
|
||||
if got["LATE"] != "JN36" {
|
||||
t.Error("what was pending at shutdown was dropped")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,13 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return parseHamQTHSearch(body)
|
||||
}
|
||||
|
||||
// parseHamQTHSearch turns one answer into a Result. Split out from the request
|
||||
// so the field mapping can be checked against a captured payload — which is how
|
||||
// the missing full name and picture were found.
|
||||
func parseHamQTHSearch(body []byte) (Result, error) {
|
||||
var resp hamqthRoot
|
||||
if err := xml.Unmarshal(body, &resp); err != nil {
|
||||
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
|
||||
@@ -112,11 +119,20 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
if s.Callsign == "" {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
// <nick> is the name the operator goes BY on the air, which is what belongs
|
||||
// in a log — "Igor", not "Igor Vladimirovich Getman". adr_name is the postal
|
||||
// name and is used only when there is nothing else, so a record carrying
|
||||
// neither nick nor name no longer resolves to blank.
|
||||
name := strings.TrimSpace(s.Nick + " " + s.LastName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(s.AdrName)
|
||||
}
|
||||
r := Result{
|
||||
Callsign: strings.ToUpper(s.Callsign),
|
||||
Name: strings.TrimSpace(s.Nick + " " + s.LastName),
|
||||
Name: name,
|
||||
QTH: firstNonEmpty(s.QTH, s.AdrCity),
|
||||
Address: s.AdrStreet1,
|
||||
Zip: s.AdrZip,
|
||||
State: strings.ToUpper(s.USState),
|
||||
County: s.USCounty,
|
||||
Country: firstNonEmpty(s.AdrCountry, s.Country),
|
||||
@@ -124,6 +140,8 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
|
||||
Continent: strings.ToUpper(s.Continent),
|
||||
Email: s.Email,
|
||||
QSLVia: s.QSLVia,
|
||||
Web: s.Web,
|
||||
ImageURL: s.Picture,
|
||||
}
|
||||
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
|
||||
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
|
||||
@@ -182,4 +200,14 @@ type hamqthSearch struct {
|
||||
Continent string `xml:"continent"`
|
||||
Email string `xml:"email"`
|
||||
QSLVia string `xml:"qsl_via"`
|
||||
// AdrName is the full postal name. Many records carry it and no <name> at
|
||||
// all, so building the name from <nick> + <name> silently kept the first
|
||||
// name and dropped the rest.
|
||||
AdrName string `xml:"adr_name"`
|
||||
AdrZip string `xml:"adr_zip"`
|
||||
// Picture is HamQTH's profile photo. Result.ImageURL was documented as "QRZ
|
||||
// only" because this element was never read — QRZ calls the same thing
|
||||
// <image>.
|
||||
Picture string `xml:"picture"`
|
||||
Web string `xml:"web"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A real HamQTH answer, captured from the live API.
|
||||
//
|
||||
// Two things it settles. The email IS returned and must reach the QSO. And the
|
||||
// element names are not the ones a reading of the documentation suggests:
|
||||
// there is no <name> here at all, the full name is <adr_name>, and the picture
|
||||
// is <picture> where QRZ calls it <image>. The stray <div> is an advert the
|
||||
// server injects into its own XML; the parser has to shrug it off.
|
||||
const hamqthEU1EU = `<?xml version="1.0"?>
|
||||
<HamQTH xmlns="https://www.hamqth.com" version="2.8">
|
||||
<div id="in-page-channel-node-id" data-channel-name="in_page_channel_Sux7_K"/>
|
||||
<search>
|
||||
<callsign>eu1eu</callsign>
|
||||
<nick>Igor</nick>
|
||||
<qth>Minsk-5</qth>
|
||||
<country>Belarus</country>
|
||||
<adif>27</adif>
|
||||
<itu>29</itu>
|
||||
<cq>16</cq>
|
||||
<grid>KO33SV</grid>
|
||||
<adr_name>Igor Vladimirovich Getman</adr_name>
|
||||
<adr_street1>A/ya 143</adr_street1>
|
||||
<adr_city>Minsk-5</adr_city>
|
||||
<adr_zip>220005</adr_zip>
|
||||
<adr_country>Belarus</adr_country>
|
||||
<adr_adif>27</adr_adif>
|
||||
<district>WAARB-LE</district>
|
||||
<lotw>?</lotw>
|
||||
<qsldirect>?</qsldirect>
|
||||
<qsl>?</qsl>
|
||||
<eqsl>Y</eqsl>
|
||||
<email>eu1eu@mail.ru</email>
|
||||
<latitude>53.88999938964844</latitude>
|
||||
<longitude>27.59000015258789</longitude>
|
||||
<continent>EU</continent>
|
||||
<utc_offset>-2</utc_offset>
|
||||
<picture>https://www.hamqth.com/images/default/ts-930_qith_old_radios.jpg</picture>
|
||||
</search>
|
||||
</HamQTH>`
|
||||
|
||||
func TestHamQTHParsesEveryUsefulField(t *testing.T) {
|
||||
var root hamqthRoot
|
||||
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
s := root.Search
|
||||
|
||||
for _, c := range []struct{ name, got, want string }{
|
||||
{"callsign", s.Callsign, "eu1eu"},
|
||||
{"email", s.Email, "[email protected]"},
|
||||
{"grid", s.Grid, "KO33SV"},
|
||||
{"qth", s.QTH, "Minsk-5"},
|
||||
{"street", s.AdrStreet1, "A/ya 143"},
|
||||
{"city", s.AdrCity, "Minsk-5"},
|
||||
{"country", s.AdrCountry, "Belarus"},
|
||||
{"continent", s.Continent, "EU"},
|
||||
{"dxcc", s.DXCC, "27"},
|
||||
{"cq", s.CQ, "16"},
|
||||
{"itu", s.ITU, "29"},
|
||||
} {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fields the parser was blind to. Each is data OpsLog has somewhere to put.
|
||||
func TestHamQTHParsesTheFieldsThatWereMissing(t *testing.T) {
|
||||
var root hamqthRoot
|
||||
if err := xml.Unmarshal([]byte(hamqthEU1EU), &root); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
s := root.Search
|
||||
|
||||
// The postal name, the fallback when a record carries no nick and no name.
|
||||
if s.AdrName != "Igor Vladimirovich Getman" {
|
||||
t.Errorf("adr_name = %q", s.AdrName)
|
||||
}
|
||||
if s.LastName != "" {
|
||||
t.Errorf("this record has no <name>; got %q", s.LastName)
|
||||
}
|
||||
// The profile picture. Result.ImageURL existed and said "QRZ only" — HamQTH
|
||||
// sends one too, under a different element name.
|
||||
if s.Picture == "" {
|
||||
t.Error("picture not parsed")
|
||||
}
|
||||
if s.AdrZip != "220005" {
|
||||
t.Errorf("adr_zip = %q", s.AdrZip)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: what the provider hands back must carry the email, the full name
|
||||
// and the picture.
|
||||
func TestHamQTHResultCarriesTheLot(t *testing.T) {
|
||||
r, err := parseHamQTHSearch([]byte(hamqthEU1EU))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if r.Email != "[email protected]" {
|
||||
t.Errorf("Email = %q — this is what was reported missing", r.Email)
|
||||
}
|
||||
// The nick, not the postal name: a log wants the name the operator goes by
|
||||
// on the air. adr_name is only the fallback when there is nothing else.
|
||||
if r.Name != "Igor" {
|
||||
t.Errorf("Name = %q, want the on-air nick", r.Name)
|
||||
}
|
||||
if r.ImageURL == "" {
|
||||
t.Error("ImageURL empty — HamQTH sent a picture")
|
||||
}
|
||||
if r.Grid != "KO33SV" {
|
||||
t.Errorf("Grid = %q", r.Grid)
|
||||
}
|
||||
if r.Lat < 53.8 || r.Lat > 53.9 {
|
||||
t.Errorf("Lat = %v", r.Lat)
|
||||
}
|
||||
if r.DXCC != 27 || r.CQZ != 16 || r.ITUZ != 29 {
|
||||
t.Errorf("dxcc/cq/itu = %d/%d/%d", r.DXCC, r.CQZ, r.ITUZ)
|
||||
}
|
||||
if r.Address != "A/ya 143" {
|
||||
t.Errorf("Address = %q", r.Address)
|
||||
}
|
||||
}
|
||||
+32
-23
@@ -21,23 +21,29 @@ var ErrNotFound = errors.New("callsign not found")
|
||||
|
||||
// Result is the normalized lookup output regardless of provider.
|
||||
type Result struct {
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
CQZ int `json:"cqz,omitempty"`
|
||||
ITUZ int `json:"ituz,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now)
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
CQZ int `json:"cqz,omitempty"`
|
||||
ITUZ int `json:"ituz,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
// Web is the operator's own site. The QSO table has had a `web` column all
|
||||
// along and nothing ever filled it, because no provider mapping read the
|
||||
// field.
|
||||
Web string `json:"web,omitempty"`
|
||||
// Zip is the postal code. HamQTH and QRZ both send one.
|
||||
Zip string `json:"zip,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"` // profile picture URL
|
||||
Source string `json:"source"` // "qrz", "hamqth", or "cache"
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
@@ -446,12 +452,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||
source, fetched_at
|
||||
web, zip, source, fetched_at
|
||||
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||
var (
|
||||
r Result
|
||||
name, qth, addr, state, cnty sql.NullString
|
||||
country, grid, cont, email, qslVia, image sql.NullString
|
||||
web, zip sql.NullString
|
||||
src string
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
@@ -459,7 +466,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
)
|
||||
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||
&country, &grid, &lat, &lon,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip,
|
||||
&src, &fetched); err != nil {
|
||||
return Result{}, false
|
||||
}
|
||||
@@ -481,6 +488,8 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
r.Lon = lon.Float64
|
||||
r.Continent = cont.String
|
||||
r.Email = email.String
|
||||
r.Web = web.String
|
||||
r.Zip = zip.String
|
||||
r.QSLVia = qslVia.String
|
||||
r.ImageURL = image.String
|
||||
r.DXCC = int(dxcc.Int64)
|
||||
@@ -497,7 +506,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
updateCols := []string{
|
||||
"name", "qth", "address", "state", "cnty",
|
||||
"country", "grid", "lat", "lon",
|
||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
|
||||
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip",
|
||||
"source", "fetched_at",
|
||||
}
|
||||
// The lookup cache always lives in the local SQLite database, so SQLite
|
||||
@@ -510,8 +519,8 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||
country, grid, lat, lon,
|
||||
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||
source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?)
|
||||
web, zip, source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?, ?,?)
|
||||
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
|
||||
_, err := c.db.ExecContext(ctx, q,
|
||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||
@@ -520,7 +529,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||
nullable(r.ImageURL),
|
||||
nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
|
||||
r.Source, db.NowISO(),
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -4,14 +4,14 @@ import "testing"
|
||||
|
||||
func TestTitleCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"NOEL CHENAVARD": "Noel Chenavard",
|
||||
"VETRAZ-MONTHOUX": "Vetraz-Monthoux",
|
||||
"o'brien": "O'Brien",
|
||||
"NOEL CHENAVARD": "Noel Chenavard",
|
||||
"VETRAZ-MONTHOUX": "Vetraz-Monthoux",
|
||||
"o'brien": "O'Brien",
|
||||
"866 ROUTE DES VOIRONS": "866 Route Des Voirons",
|
||||
"PARIS": "Paris",
|
||||
"": "",
|
||||
" saint-étienne ": "Saint-Étienne",
|
||||
"JOHN": "John",
|
||||
"PARIS": "Paris",
|
||||
"": "",
|
||||
" saint-étienne ": "Saint-Étienne",
|
||||
"JOHN": "John",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := titleCase(in); got != want {
|
||||
|
||||
+35
-16
@@ -21,8 +21,14 @@ type QRZ struct {
|
||||
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
session string
|
||||
// PreferNickname takes QRZ's <nickname> over the composed first+last name
|
||||
// when the operator has published one. It is the name they go BY on the air,
|
||||
// which is what belongs in a log — HamQTH's <nick> is already used that way,
|
||||
// and this brings QRZ into line for operators who want it.
|
||||
PreferNickname bool
|
||||
|
||||
mu sync.Mutex
|
||||
session string
|
||||
loggedAt time.Time
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
|
||||
}
|
||||
r := Result{
|
||||
Callsign: strings.ToUpper(c.Call),
|
||||
Name: joinName(c.FName, c.Name),
|
||||
Name: qrzName(q.PreferNickname, c.Nickname, c.FName, c.Name),
|
||||
QTH: c.Addr2,
|
||||
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
|
||||
State: strings.ToUpper(c.State),
|
||||
@@ -169,6 +175,7 @@ type qrzSession struct {
|
||||
type qrzCallsign struct {
|
||||
Call string `xml:"call"`
|
||||
FName string `xml:"fname"`
|
||||
Nickname string `xml:"nickname"` // the name the operator goes by on the air
|
||||
Name string `xml:"name"`
|
||||
Addr1 string `xml:"addr1"`
|
||||
Addr2 string `xml:"addr2"`
|
||||
@@ -213,19 +220,6 @@ func composeQRZAddress(addr1, addr2, zip, country string) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func joinName(first, last string) string {
|
||||
first = strings.TrimSpace(first)
|
||||
last = strings.TrimSpace(last)
|
||||
switch {
|
||||
case first != "" && last != "":
|
||||
return first + " " + last
|
||||
case first != "":
|
||||
return first
|
||||
default:
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(s ...string) string {
|
||||
for _, v := range s {
|
||||
v = strings.TrimSpace(v)
|
||||
@@ -235,3 +229,28 @@ func firstNonEmpty(s ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// qrzName picks what goes in the log's Name field.
|
||||
//
|
||||
// The FIRST name only. A log greets an operator, it does not address an
|
||||
// envelope: "Robert" is what goes out on the air, and "Robert Smith" filled the
|
||||
// field with something no one would ever send. The surname stays available in
|
||||
// the record; it simply is not the name of the contact.
|
||||
//
|
||||
// The nickname is taken ahead of it when the operator asked for that AND QRZ
|
||||
// has one — a blank nickname must never blank the name, which is the whole
|
||||
// reason this is a fallback rather than a swap.
|
||||
//
|
||||
// The surname is the last resort: a record with no first name at all is better
|
||||
// answered with a surname than with nothing.
|
||||
func qrzName(preferNickname bool, nickname, fname, name string) string {
|
||||
if preferNickname {
|
||||
if n := strings.TrimSpace(nickname); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
if f := strings.TrimSpace(fname); f != "" {
|
||||
return f
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package lookup
|
||||
|
||||
import "testing"
|
||||
|
||||
// What goes in a log's Name field is the FIRST name. A log greets an operator;
|
||||
// it does not address an envelope. "Robert Smith" filled the field with
|
||||
// something no one would ever send on the air.
|
||||
//
|
||||
// The nickname wins ahead of it when asked for — but a published nickname is
|
||||
// optional, so an empty one must fall through rather than blank the name. That
|
||||
// is the whole difference between a fallback and a swap.
|
||||
func TestQRZName(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prefer bool
|
||||
nickname, fname, last string
|
||||
want string
|
||||
}{
|
||||
{"first name only, never the surname", false, "Bob", "Robert", "Smith", "Robert"},
|
||||
{"nickname when asked for", true, "Bob", "Robert", "Smith", "Bob"},
|
||||
{"no nickname published falls back to the FIRST name", true, "", "Robert", "Smith", "Robert"},
|
||||
{"a blank nickname is not a nickname", true, " ", "Robert", "Smith", "Robert"},
|
||||
{"option off ignores the nickname", false, "Bob", "Robert", "Smith", "Robert"},
|
||||
{"surname is the last resort, not the default", true, "", "", "Smith", "Smith"},
|
||||
{"nothing published at all", false, "", "", "", ""},
|
||||
{"nickname alone", true, "Bob", "", "", "Bob"},
|
||||
} {
|
||||
if got := qrzName(tc.prefer, tc.nickname, tc.fname, tc.last); got != tc.want {
|
||||
t.Errorf("%s: qrzName(%v, %q, %q, %q) = %q, want %q",
|
||||
tc.name, tc.prefer, tc.nickname, tc.fname, tc.last, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -81,7 +81,16 @@ type Config struct {
|
||||
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
|
||||
// it must not block: the broker's buffer is what pays for it if it does.
|
||||
OnSpot func(Spot)
|
||||
Logf func(string, ...any)
|
||||
// OnGrid receives the transmitter of EVERY message, before any geographic
|
||||
// filtering, for the callsign-to-locator store. Same goroutine as OnSpot and
|
||||
// the same rule: do not block.
|
||||
OnGrid func(call, grid string)
|
||||
// RxGrids filters at the BROKER: only reports collected by a receiver in one
|
||||
// of these squares are sent at all. Empty keeps the old behaviour, which was
|
||||
// to receive the world and discard it here — measured at 83 messages a second
|
||||
// for the four opening bands, of which about one in a hundred survived.
|
||||
RxGrids []string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the MQTT connection and its subscriptions.
|
||||
@@ -115,6 +124,32 @@ func New(cfg Config) *Watcher {
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// topics 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 the receiver's square is a level the broker can filter on, and a band of
|
||||
// "+" means every band. Filtering by RECEIVER square rather than by receiver
|
||||
// DXCC is deliberate: measured on 20 m, one country ranged from 1.2 messages a
|
||||
// second (OH) to 72.5 (K), because a DXCC can be a continent. By square 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.
|
||||
func (w *Watcher) topics() []string {
|
||||
out := []string{}
|
||||
for _, b := range w.cfg.Bands {
|
||||
if len(w.cfg.RxGrids) == 0 {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||
continue
|
||||
}
|
||||
for _, g := range w.cfg.RxGrids {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/+/+/+/+/"+strings.ToUpper(g)+"/+/+")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start connects and subscribes. Safe to call when already running.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
@@ -143,10 +178,7 @@ func (w *Watcher) Start() error {
|
||||
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||
for _, b := range w.cfg.Bands {
|
||||
// Every mode, every pair of stations, on this band. That firehose IS
|
||||
// the point: the detector's job is to find the shape in it.
|
||||
topic := "pskr/filter/v2/" + b + "/#"
|
||||
for _, topic := range w.topics() {
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
||||
continue
|
||||
@@ -206,6 +238,13 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// The locator store takes every transmitter, before any of the geography
|
||||
// below. What it wants is "which square is this callsign in", and that is
|
||||
// true whoever happened to hear the report.
|
||||
if w.cfg.OnGrid != nil {
|
||||
w.cfg.OnGrid(call, grid[:4])
|
||||
}
|
||||
|
||||
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
|
||||
// between a useful feed and a world map.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pskr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The receiver square is a level the BROKER can filter on, which is the whole
|
||||
// point: measured on the live feed, the four opening bands unfiltered are 83
|
||||
// messages a second of which about one in a hundred survives the NearKm test.
|
||||
// One ring of squares is under two a second, and the same for every operator —
|
||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K) on one band.
|
||||
func TestTopicsFilterOnTheReceiverSquare(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"6m"}, RxGrids: []string{"jn36", "JN37"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want one subscription per band × square, got %v", got)
|
||||
}
|
||||
// Level order: band/mode/txcall/rxcall/txgrid/RXGRID/txdxcc/rxdxcc
|
||||
want := "pskr/filter/v2/6m/+/+/+/+/JN36/+/+"
|
||||
if got[0] != want {
|
||||
t.Errorf("topic = %q, want %q", got[0], want)
|
||||
}
|
||||
if !strings.Contains(got[1], "/JN37/") {
|
||||
t.Errorf("square not upper-cased into the topic: %q", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// With no squares the old behaviour stands: receive the band and decide here.
|
||||
func TestTopicsWithoutSquares(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"10m", "2m"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 || got[0] != "pskr/filter/v2/10m/#" {
|
||||
t.Errorf("unfiltered topics = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Grid chasing wants every band. "+" is the MQTT single-level wildcard, so one
|
||||
// subscription per square covers the lot instead of one per band per square.
|
||||
func TestTopicsAllBands(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"+"}, RxGrids: []string{"JN36"}})
|
||||
got := w.topics()
|
||||
if len(got) != 1 || got[0] != "pskr/filter/v2/+/+/+/+/+/JN36/+/+" {
|
||||
t.Errorf("all-band topic = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// OrderedIDs against a real SQLite file, with qso_date stored the way the repo
|
||||
// actually writes it.
|
||||
//
|
||||
// The first version scanned that column straight into a time.Time. It compiles,
|
||||
// it reads correctly, and it fails at run time — the column holds a formatted
|
||||
// STRING. The error was swallowed into "the column will be empty", which is
|
||||
// exactly what shipped: a column that was always blank.
|
||||
func TestOrderedIDsAgainstRealSQLite(t *testing.T) {
|
||||
conn, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if _, err := conn.Exec(`CREATE TABLE qso (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
callsign TEXT NOT NULL,
|
||||
qso_date TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Inserted NEWEST first, so the ids run opposite to the dates — what an
|
||||
// imported ADIF produces, and the whole reason the id is not the number.
|
||||
for _, row := range []struct {
|
||||
call string
|
||||
at time.Time
|
||||
}{
|
||||
{"NEWEST", time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)},
|
||||
{"MIDDLE", time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)},
|
||||
{"OLDEST", time.Date(1999, 5, 5, 10, 0, 0, 0, time.UTC)},
|
||||
} {
|
||||
if _, err := conn.Exec(`INSERT INTO qso (callsign, qso_date) VALUES (?, ?)`,
|
||||
row.call, row.at.UTC().Format(isoMillis)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ids, newest, err := NewRepo(conn).OrderedIDs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("OrderedIDs: %v — this is the failure that emptied the column", err)
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("got %d ids, want 3", len(ids))
|
||||
}
|
||||
// Rows 1,2,3 were inserted newest→oldest, so chronological order is 3,2,1.
|
||||
if ids[0] != 3 || ids[1] != 2 || ids[2] != 1 {
|
||||
t.Errorf("order = %v, want the oldest contact first (3,2,1)", ids)
|
||||
}
|
||||
if newest.Year() != 2026 {
|
||||
t.Errorf("newest = %v, want the 2026 contact", newest)
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -47,7 +47,15 @@ func MergeNonZero(dst *QSO, src QSO) {
|
||||
// import/export. Pointers are used to distinguish "absent" from "zero".
|
||||
// Anything in ADIF that is not a promoted column lands in Extras.
|
||||
type QSO struct {
|
||||
ID int64 `json:"id"`
|
||||
ID int64 `json:"id"`
|
||||
// Number is the contact's position in the log, oldest = 1.
|
||||
//
|
||||
// NOT a column and NOT the ID: the primary key follows insertion order, so
|
||||
// importing an old ADIF gives the oldest contacts the highest ids. This is a
|
||||
// rank over qso_date, computed by the App layer and stamped on the way out —
|
||||
// which is why it is `json:"-"`-adjacent in spirit: nothing reads or writes
|
||||
// it in SQL.
|
||||
Number int `json:"number,omitempty"`
|
||||
Callsign string `json:"callsign"`
|
||||
QSODate time.Time `json:"qso_date"` // start, UTC
|
||||
QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
|
||||
@@ -3170,3 +3178,39 @@ func (r *Repo) WorkedGridKeys(ctx context.Context, normMode func(string) string)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OrderedIDs returns every QSO id in chronological order, oldest first.
|
||||
//
|
||||
// The mirror of ListFiltered's "qso_date DESC, id DESC", so the tie-break is the
|
||||
// same one the grid displays and a contact cannot change number depending on
|
||||
// which way it is read.
|
||||
//
|
||||
// One query, ids only: the caller turns it into a rank once and keeps it in
|
||||
// memory. Asking the database for a row's rank per row would be a correlated
|
||||
// count over the whole log for each of thirty thousand rows.
|
||||
func (r *Repo) OrderedIDs(ctx context.Context) ([]int64, time.Time, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, qso_date FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, 4096)
|
||||
// qso_date is stored as a formatted STRING, not a driver date. Scanning it
|
||||
// into a time.Time silently fails on SQLite, which is how this returned an
|
||||
// error and left the whole column empty.
|
||||
var lastDate string
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var at string
|
||||
if err := rows.Scan(&id, &at); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
out = append(out, id)
|
||||
lastDate = at // ascending, so the last row read is the newest
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
// Parsed once, for the newest row only — the ordering came from SQL.
|
||||
return out, parseTimeLoose(lastDate), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package spe
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decodeCSV marks the client connected on EVERY frame it parses, and the poll
|
||||
// goroutine can be mid-read while PowerOff runs. So the frame from a second ago
|
||||
// landed after PowerOff had marked the amp offline and put it straight back —
|
||||
// the console kept showing the amplifier on until the operator pressed OFF a
|
||||
// second time.
|
||||
func TestFrameInFlightDoesNotResurrectASwitchedOffAmp(t *testing.T) {
|
||||
c := &Client{}
|
||||
const frame = ",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,"
|
||||
|
||||
// Normal running: a frame marks it connected and reads OPERATE.
|
||||
c.decodeCSV(frame)
|
||||
if !c.GetStatus().Connected || !c.GetStatus().Operate {
|
||||
t.Fatal("a status frame should mark the amp connected")
|
||||
}
|
||||
|
||||
// The operator presses OFF. This is what PowerOff does, in order.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(3 * time.Second)
|
||||
c.statusMu.Unlock()
|
||||
c.setErr(errSwitchedOff{})
|
||||
|
||||
// A frame that was already on the wire arrives now.
|
||||
c.decodeCSV(frame)
|
||||
|
||||
if c.GetStatus().Connected {
|
||||
t.Error("a frame from before the switch-off put the amp back on — this is the double-click on OFF")
|
||||
}
|
||||
}
|
||||
|
||||
// An amp switched back on at its own front panel must reappear without OpsLog
|
||||
// being told, so the suppression has to expire rather than latch.
|
||||
func TestSuppressionExpires(t *testing.T) {
|
||||
c := &Client{}
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(-time.Second) // window already past
|
||||
c.statusMu.Unlock()
|
||||
c.setErr(errSwitchedOff{})
|
||||
|
||||
c.decodeCSV(",13K,O,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,")
|
||||
if !c.GetStatus().Connected {
|
||||
t.Error("an amp answering after the window must come back on its own")
|
||||
}
|
||||
}
|
||||
|
||||
type errSwitchedOff struct{}
|
||||
|
||||
func (errSwitchedOff) Error() string { return "switched off" }
|
||||
+56
-2
@@ -91,6 +91,15 @@ type Client struct {
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
lastRaw string // last raw status payload logged (log only on change)
|
||||
// offUntil suppresses status frames that were already in flight when the amp
|
||||
// was switched off. decodeCSV marks the client connected on every frame it
|
||||
// parses, and the poll goroutine can be mid-read while PowerOff runs — so the
|
||||
// frame from a second ago resurrected an amplifier the operator had just
|
||||
// switched off, and the console only caught up when they pressed OFF twice.
|
||||
//
|
||||
// Time-bounded rather than a latch: an amp switched back on at its own front
|
||||
// panel must reappear on its own.
|
||||
offUntil time.Time
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
@@ -177,6 +186,12 @@ func (c *Client) PowerOn() error {
|
||||
if c.GetStatus().Connected {
|
||||
return nil
|
||||
}
|
||||
// Switching on cancels the suppression window: the operator wants frames
|
||||
// believed again, and waiting out a timer they cannot see would read as the
|
||||
// power-on having failed.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Time{}
|
||||
c.statusMu.Unlock()
|
||||
// The poll goroutine may still be inside a blocking read on the old handle;
|
||||
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
|
||||
// the open for a little longer than that.
|
||||
@@ -205,9 +220,17 @@ func (c *Client) PowerOn() error {
|
||||
time.Sleep(wakePulse)
|
||||
return set(true)
|
||||
}
|
||||
if err = pulse(sp.SetRTS); err == nil {
|
||||
err = pulse(sp.SetDTR)
|
||||
// Each line reported separately. Not every USB-serial chip honours the modem
|
||||
// lines, and some refuse them without saying so — on an amplifier that never
|
||||
// wakes, "which of the two failed, or neither" is the whole diagnosis, and a
|
||||
// single combined error cannot give it.
|
||||
rtsErr := pulse(sp.SetRTS)
|
||||
dtrErr := pulse(sp.SetDTR)
|
||||
if err = rtsErr; err == nil {
|
||||
err = dtrErr
|
||||
}
|
||||
applog.Printf("spe: power ON pulse on %s (model=%q transport=%s) — RTS err=%v, DTR err=%v",
|
||||
c.cfg.ComPort, c.GetStatus().Model, c.cfg.Transport, rtsErr, dtrErr)
|
||||
// Booting, the amp re-enumerates its USB interface, which leaves this handle
|
||||
// pointing at a device that no longer exists — the amp came up and OpsLog
|
||||
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
|
||||
@@ -216,6 +239,26 @@ func (c *Client) PowerOn() error {
|
||||
c.dropLocked()
|
||||
c.mu.Unlock()
|
||||
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
|
||||
|
||||
// Say whether it actually woke. "The ON button does nothing" is a report with
|
||||
// three different causes — the pulse was refused, the pulse went out and the
|
||||
// amp ignored it, or the amp came up and the UI missed it — and only the log
|
||||
// can separate them. Watched off the caller's goroutine so the click returns
|
||||
// at once.
|
||||
go func() {
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(time.Second)
|
||||
if c.GetStatus().Connected {
|
||||
applog.Printf("spe: power ON — amp answered after %.0fs",
|
||||
15-time.Until(deadline).Seconds())
|
||||
return
|
||||
}
|
||||
}
|
||||
applog.Printf("spe: power ON — no answer within 15s on %s. The pulse was sent; "+
|
||||
"either this amplifier does not wake on RTS/DTR, or the adapter does not drive those lines",
|
||||
c.cfg.ComPort)
|
||||
}()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -226,6 +269,14 @@ func (c *Client) PowerOn() error {
|
||||
// the poll loop keeps reopening the port with both lines high and that has
|
||||
// never woken it — only the deliberate low→high sequence in PowerOn does.
|
||||
func (c *Client) PowerOff() error {
|
||||
// Close the window BEFORE the key goes out, so a frame already travelling up
|
||||
// the wire cannot land after setErr and undo it. Three seconds covers a poll
|
||||
// interval with room to spare; after that a real answer means the amp is
|
||||
// genuinely alive again, which is what happens if it is switched back on at
|
||||
// its own front panel.
|
||||
c.statusMu.Lock()
|
||||
c.offUntil = time.Now().Add(3 * time.Second)
|
||||
c.statusMu.Unlock()
|
||||
err := c.sendCmd(cmdOff)
|
||||
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
||||
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
||||
@@ -482,6 +533,9 @@ func (c *Client) decodeCSV(payload string) {
|
||||
c.lastRaw = payload
|
||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||
}
|
||||
if time.Now().Before(c.offUntil) {
|
||||
return // a frame from before the switch-off — the amp is on its way down
|
||||
}
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A per-band tune frequency goes straight to the antenna as a command, so a
|
||||
// value that is not actually in that band has to be refused rather than obeyed.
|
||||
// One wrong digit sends the elements travelling to a length that is wrong for
|
||||
// the band the operator is on — and on a SteppIR that journey inhibits transmit
|
||||
// the whole way.
|
||||
func TestNormMotorBandFreqsRefusesOutOfBand(t *testing.T) {
|
||||
in := map[string]int{
|
||||
"20m": 14050, // fine, CW end
|
||||
"40m": 7005, // fine
|
||||
"6m": 50313, // fine, FT8
|
||||
"15m": 1450, // a digit lost — lands in the broadcast band
|
||||
"10m": 28400000, // Hz typed where kHz was asked
|
||||
"17m": 14100, // right number, wrong band
|
||||
"30m": 0, // not set
|
||||
"80m": 3750, // not a band this antenna covers at all
|
||||
"bogus": 14100, // not a band
|
||||
}
|
||||
got := normMotorBandFreqs(in)
|
||||
want := map[string]int{"40m": 7005, "20m": 14050, "6m": 50313}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("kept %v, want %v", got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s = %d, want %d", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stored form round-trips, in canonical band order rather than map order so
|
||||
// the settings row does not churn between saves.
|
||||
func TestMotorBandFreqsRoundTrip(t *testing.T) {
|
||||
m := map[string]int{"20m": 14050, "40m": 7005, "6m": 50313}
|
||||
enc := encodeMotorBandFreqs(m)
|
||||
if enc != "40m=7005,20m=14050,6m=50313" {
|
||||
t.Errorf("encoded %q — want canonical low→high order", enc)
|
||||
}
|
||||
back := decodeMotorBandFreqs(enc)
|
||||
for k, v := range m {
|
||||
if back[k] != v {
|
||||
t.Errorf("round trip lost %s: %d → %d", k, v, back[k])
|
||||
}
|
||||
}
|
||||
// Garbage in one entry must cost only that entry.
|
||||
part := decodeMotorBandFreqs("40m=7005,20m=oops,6m=50313")
|
||||
if part["40m"] != 7005 || part["6m"] != 50313 {
|
||||
t.Errorf("one bad entry took the others down: %v", part)
|
||||
}
|
||||
if _, ok := part["20m"]; ok {
|
||||
t.Errorf("kept an unparseable entry: %v", part)
|
||||
}
|
||||
}
|
||||
|
||||
// An unset band falls back to its default, which is what makes the Settings box
|
||||
// safe to leave empty.
|
||||
func TestMotorTuneKHzForBandFallsBack(t *testing.T) {
|
||||
m := map[string]int{"20m": 14050}
|
||||
if got := motorTuneKHzForBand(m, "20m"); got != 14050 {
|
||||
t.Errorf("chosen frequency ignored: %d", got)
|
||||
}
|
||||
if got := motorTuneKHzForBand(m, "15m"); got != 21150 {
|
||||
t.Errorf("15m = %d, want the 21150 default", got)
|
||||
}
|
||||
if got := motorTuneKHzForBand(m, "80m"); got != 0 {
|
||||
t.Errorf("80m = %d, want 0 — not a motor band", got)
|
||||
}
|
||||
// Every default must itself be in its band, or the fallback ships the very
|
||||
// fault normMotorBandFreqs exists to catch.
|
||||
for _, b := range motorBands {
|
||||
if got := bandForHz(int64(b.defKHz) * 1000); got != b.name {
|
||||
t.Errorf("default %d kHz for %s reads as %q", b.defKHz, b.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The tracking mode decides how often a motorized antenna's elements run, so a
|
||||
// value that fails to parse must not silently become the most aggressive
|
||||
// setting. Anything unrecognised — including the empty string every config
|
||||
// written before this option existed contains — has to land on the threshold
|
||||
// mode, which is exactly what those configs already did.
|
||||
func TestNormMotorTrackMode(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"always", motorTrackAlways},
|
||||
{"ALWAYS", motorTrackAlways},
|
||||
{" band ", motorTrackBand},
|
||||
{"step", motorTrackStep},
|
||||
{"", motorTrackStep}, // never configured — behaves as before
|
||||
{"everytime", motorTrackStep}, // near-miss, not "always"
|
||||
{"per-band", motorTrackStep}, // near-miss, not "band"
|
||||
{"25", motorTrackStep}, // a step value fed in by mistake
|
||||
} {
|
||||
if got := normMotorTrackMode(tc.in); got != tc.want {
|
||||
t.Errorf("normMotorTrackMode(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Band mode compares the band the rig is on against the band the antenna was
|
||||
// last commanded for. That comparison is bandForHz, and the property it has to
|
||||
// have is that two frequencies far apart within one band agree while two
|
||||
// frequencies close together across a band edge do not — otherwise the antenna
|
||||
// either never moves or moves on every QSY.
|
||||
func TestBandForHzDrivesBandTracking(t *testing.T) {
|
||||
same := [][2]int64{
|
||||
{14000000, 14350000}, // both ends of 20 m — one band, no move
|
||||
{7000000, 7200000}, // 40 m
|
||||
{50000000, 52000000}, // 6 m, a wide one
|
||||
}
|
||||
for _, p := range same {
|
||||
if a, b := bandForHz(p[0]), bandForHz(p[1]); a != b || a == "" {
|
||||
t.Errorf("%.3f MHz is %q but %.3f MHz is %q — band mode would re-tune inside one band",
|
||||
float64(p[0])/1e6, a, float64(p[1])/1e6, b)
|
||||
}
|
||||
}
|
||||
// A small QSY that crosses from 30 m into 20 m has to read as a band change.
|
||||
if a, b := bandForHz(10150000), bandForHz(14000000); a == b {
|
||||
t.Errorf("30 m and 20 m both read %q — band mode would never re-tune between them", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
func at(s string) time.Time {
|
||||
t, err := time.Parse("2006-01-02 15:04", s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// The number is the contact's rank in the WHOLE log, not its position in what
|
||||
// the grid happens to be showing. Ranking inside the result would renumber
|
||||
// every contact the moment a filter is applied, and QSO #1 would change
|
||||
// identity as the operator typed.
|
||||
func TestQSONumberIsGlobalNotPerPage(t *testing.T) {
|
||||
a := &App{}
|
||||
// The log, oldest first: ids are deliberately NOT in date order, which is
|
||||
// exactly what an imported ADIF produces.
|
||||
a.qsoNumbers = map[int64]int{
|
||||
770: 1, // oldest, imported last so it has the highest id
|
||||
101: 2,
|
||||
102: 3,
|
||||
103: 4,
|
||||
}
|
||||
|
||||
// A filtered page holding only two of them, newest first as the grid asks.
|
||||
page := []qso.QSO{{ID: 103}, {ID: 770}}
|
||||
a.stampQSONumbers(page)
|
||||
|
||||
if page[0].Number != 4 {
|
||||
t.Errorf("id 103 numbered %d, want 4", page[0].Number)
|
||||
}
|
||||
if page[1].Number != 1 {
|
||||
t.Errorf("id 770 numbered %d, want 1 — the oldest contact, whatever its id", page[1].Number)
|
||||
}
|
||||
}
|
||||
|
||||
// A contact logged now is the newest, so it takes the next number without
|
||||
// rereading the log — a full scan per QSO would be felt in a contest run.
|
||||
func TestNewQSOTakesTheNextNumber(t *testing.T) {
|
||||
a := &App{}
|
||||
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||
a.qsoNumMax = at("2026-08-13 10:00")
|
||||
|
||||
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||
if got := a.qsoNumbers[9]; got != 4 {
|
||||
t.Errorf("new QSO numbered %d, want 4", got)
|
||||
}
|
||||
if !a.qsoNumMax.Equal(at("2026-08-13 11:00")) {
|
||||
t.Error("the newest date was not carried forward")
|
||||
}
|
||||
}
|
||||
|
||||
// A contact entered with an OLDER date belongs in the middle of the order.
|
||||
// Appending it would number it last, which is wrong — so the map is dropped and
|
||||
// rebuilt correctly instead.
|
||||
func TestBackdatedQSOForcesARebuild(t *testing.T) {
|
||||
a := &App{}
|
||||
a.qsoNumbers = map[int64]int{1: 1, 2: 2, 3: 3}
|
||||
a.qsoNumMax = at("2026-08-13 10:00")
|
||||
|
||||
a.noteQSONumbered(9, at("2020-01-01 09:00"))
|
||||
if a.qsoNumbers != nil {
|
||||
t.Errorf("a back-dated QSO was appended as the newest: %v", a.qsoNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing built yet: the lazy build will see the new contact anyway, so this
|
||||
// must not create a one-entry map that then numbers the whole log wrongly.
|
||||
func TestNoteBeforeAnyBuildDoesNothing(t *testing.T) {
|
||||
a := &App{}
|
||||
a.noteQSONumbered(9, at("2026-08-13 11:00"))
|
||||
if a.qsoNumbers != nil {
|
||||
t.Errorf("built a map from a single contact: %v", a.qsoNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
// With no index available the column is simply empty — never wrong.
|
||||
func TestStampWithoutIndexLeavesZero(t *testing.T) {
|
||||
a := &App{} // no qso repo, so the index cannot be built
|
||||
page := []qso.QSO{{ID: 1}, {ID: 2}}
|
||||
a.stampQSONumbers(page)
|
||||
for _, q := range page {
|
||||
if q.Number != 0 {
|
||||
t.Errorf("invented a number without an index: %d", q.Number)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.24.6"
|
||||
appVersion = "0.25.0"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// A CQ in FT8 carries the operator's square and it is where they are NOW.
|
||||
// QRZ and HamQTH describe where they LIVE, which for an expedition is the
|
||||
// wrong side of the planet — RI0FA transmitted QN35 all evening and was logged
|
||||
// with its licence-holder's home square.
|
||||
//
|
||||
// refineGrid is the referee. What it must never do is let a directory value
|
||||
// replace one heard on the air.
|
||||
func TestRefineGridKeepsWhatWasHeardOnAir(t *testing.T) {
|
||||
for _, tc := range []struct{ have, found, want string }{
|
||||
// The expedition case: heard QN35, directory says the home square.
|
||||
{"QN35", "KO85", "QN35"},
|
||||
// Nothing heard: the directory is all there is.
|
||||
{"", "KO85", "KO85"},
|
||||
// A finer square from the SAME field is an upgrade, not a contradiction.
|
||||
{"QN35", "QN35SL", "QN35SL"},
|
||||
// A finer square from a DIFFERENT field is a contradiction — refuse it.
|
||||
{"QN35", "KO85AB", "QN35"},
|
||||
// Case and padding must not decide anything.
|
||||
{" qn35 ", "KO85", "QN35"},
|
||||
{"QN35", "", "QN35"},
|
||||
} {
|
||||
if got := refineGrid(tc.have, tc.found); got != tc.want {
|
||||
t.Errorf("refineGrid(%q, %q) = %q, want %q", tc.have, tc.found, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the ordering that makes it work: the decoded grid has to be applied
|
||||
// BEFORE the lookup, or refineGrid has nothing to defend.
|
||||
func TestDecodedGridWinsOverTheDirectory(t *testing.T) {
|
||||
a := &App{}
|
||||
a.rememberDecodeGrid("RI0FA", "QN35", "decode")
|
||||
|
||||
grid := "" // WSJT-X's ADIF carried no GRIDSQUARE, which is the usual case
|
||||
if g := a.lookupDecodeGrid("RI0FA"); g != "" {
|
||||
grid = refineGrid(grid, g)
|
||||
}
|
||||
grid = refineGrid(grid, "KO85") // then the lookup answers with the home square
|
||||
|
||||
if grid != "QN35" {
|
||||
t.Errorf("logged grid = %q, want QN35 — the square the station actually sent", grid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user